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 e3ba9b72f..ab72dbc65 100644 --- a/backend/src/ee/services/audit-log/audit-log-types.ts +++ b/backend/src/ee/services/audit-log/audit-log-types.ts @@ -407,6 +407,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", @@ -2959,6 +2967,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: { @@ -3859,6 +3938,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/license/__mocks__/license-fns.ts b/backend/src/ee/services/license/__mocks__/license-fns.ts index 729696baa..f139ff2c1 100644 --- a/backend/src/ee/services/license/__mocks__/license-fns.ts +++ b/backend/src/ee/services/license/__mocks__/license-fns.ts @@ -31,6 +31,7 @@ export const getDefaultOnPremFeatures = () => { caCrl: false, sshHostGroups: false, enterpriseSecretSyncs: false, + enterpriseCertificateSyncs: false, enterpriseAppConnections: true, machineIdentityAuthTemplates: false }; diff --git a/backend/src/ee/services/license/license-fns.ts b/backend/src/ee/services/license/license-fns.ts index a302e956a..cbe194f31 100644 --- a/backend/src/ee/services/license/license-fns.ts +++ b/backend/src/ee/services/license/license-fns.ts @@ -62,6 +62,7 @@ export const getDefaultOnPremFeatures = (): TFeatureSet => ({ sshHostGroups: false, secretScanning: false, enterpriseSecretSyncs: false, + enterpriseCertificateSyncs: false, enterpriseAppConnections: false, fips: false, eventSubscriptions: false, diff --git a/backend/src/ee/services/license/license-types.ts b/backend/src/ee/services/license/license-types.ts index cf86d9ac1..266794074 100644 --- a/backend/src/ee/services/license/license-types.ts +++ b/backend/src/ee/services/license/license-types.ts @@ -75,6 +75,7 @@ export type TFeatureSet = { sshHostGroups: false; secretScanning: false; enterpriseSecretSyncs: false; + enterpriseCertificateSyncs: false; enterpriseAppConnections: false; machineIdentityAuthTemplates: false; fips: false; diff --git a/backend/src/ee/services/permission/default-roles.ts b/backend/src/ee/services/permission/default-roles.ts index 953d0195e..83d1c44d9 100644 --- a/backend/src/ee/services/permission/default-roles.ts +++ b/backend/src/ee/services/permission/default-roles.ts @@ -13,6 +13,7 @@ import { ProjectPermissionKmipActions, ProjectPermissionMemberActions, ProjectPermissionPkiSubscriberActions, + ProjectPermissionPkiSyncActions, ProjectPermissionPkiTemplateActions, ProjectPermissionSecretActions, ProjectPermissionSecretEventActions, @@ -209,6 +210,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, @@ -462,6 +476,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, @@ -526,6 +553,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 099461f7f..d79120256 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", @@ -212,6 +222,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", @@ -244,6 +255,10 @@ export type SecretSyncSubjectFields = { secretPath: string; }; +export type PkiSyncSubjectFields = { + subscriberName: string; +}; + export type DynamicSecretSubjectFields = { environment: string; secretPath: string; @@ -308,6 +323,10 @@ export type ProjectPermissionSet = ProjectPermissionSecretSyncActions, ProjectPermissionSub.SecretSyncs | (ForcedSubject & SecretSyncSubjectFields) ] + | [ + ProjectPermissionPkiSyncActions, + ProjectPermissionSub.PkiSyncs | (ForcedSubject & PkiSyncSubjectFields) + ] | [ ProjectPermissionActions, ( @@ -480,6 +499,22 @@ const SecretSyncConditionV2Schema = z }) .partial(); +const PkiSyncConditionSchema = z + .object({ + subscriberName: z.union([ + z.string(), + z + .object({ + [PermissionConditionOperators.$EQ]: PermissionConditionSchema[PermissionConditionOperators.$EQ], + [PermissionConditionOperators.$NEQ]: PermissionConditionSchema[PermissionConditionOperators.$NEQ], + [PermissionConditionOperators.$IN]: PermissionConditionSchema[PermissionConditionOperators.$IN], + [PermissionConditionOperators.$GLOB]: PermissionConditionSchema[PermissionConditionOperators.$GLOB] + }) + .partial() + ]) + }) + .partial(); + const SecretImportConditionSchema = z .object({ environment: z.union([ @@ -943,6 +978,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/lib/api-docs/constants.ts b/backend/src/lib/api-docs/constants.ts index 53a2ca993..0bec59a76 100644 --- a/backend/src/lib/api-docs/constants.ts +++ b/backend/src/lib/api-docs/constants.ts @@ -50,6 +50,7 @@ export enum ApiDocsTags { IdentitySpecificPrivilegesV2 = "Identity Specific Privileges V2", AppConnections = "App Connections", SecretSyncs = "Secret Syncs", + PkiSyncs = "PKI Syncs", Integrations = "Integrations", ServiceTokens = "Service Tokens", AuditLogs = "Audit Logs", diff --git a/backend/src/queue/queue-service.ts b/backend/src/queue/queue-service.ts index f2f63f57a..335f25d5f 100644 --- a/backend/src/queue/queue-service.ts +++ b/backend/src/queue/queue-service.ts @@ -24,6 +24,11 @@ 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 +} from "@app/services/pki-sync/pki-sync-types"; import { TFailedIntegrationSyncEmailsPayload, TIntegrationSyncPayload, @@ -46,6 +51,7 @@ export enum QueueName { AuditLogPrune = "audit-log-prune", DailyResourceCleanUp = "daily-resource-cleanup", DailyExpiringPkiItemAlert = "daily-expiring-pki-item-alert", + PkiSyncCleanup = "pki-sync-cleanup", PkiSubscriber = "pki-subscriber", TelemetryInstanceStats = "telemtry-self-hosted-stats", IntegrationSync = "sync-integrations", @@ -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", @@ -80,6 +87,7 @@ export enum QueueJobs { AuditLogPrune = "audit-log-prune-job", DailyResourceCleanUp = "daily-resource-cleanup-job", DailyExpiringPkiItemAlert = "daily-expiring-pki-item-alert", + PkiSyncCleanup = "pki-sync-cleanup-job", SecWebhook = "secret-webhook-trigger", TelemetryInstanceStats = "telemetry-self-hosted-stats", IntegrationSync = "secret-integration-pull", @@ -91,6 +99,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 +108,9 @@ 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", SecretRotationV2QueueRotations = "secret-rotation-v2-queue-rotations", SecretRotationV2RotateSecrets = "secret-rotation-v2-rotate-secrets", SecretRotationV2SendNotification = "secret-rotation-v2-send-notification", @@ -141,6 +153,10 @@ export type TQueueJobTypes = { name: QueueJobs.DailyExpiringPkiItemAlert; payload: undefined; }; + [QueueName.PkiSyncCleanup]: { + name: QueueJobs.PkiSyncCleanup; + payload: undefined; + }; [QueueName.AuditLogPrune]: { name: QueueJobs.AuditLogPrune; payload: undefined; @@ -218,6 +234,19 @@ 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; + }; [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 8444e8765..2fb7e9ff7 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -248,6 +248,10 @@ 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 { pkiSyncCleanupQueueServiceFactory } from "@app/services/pki-sync/pki-sync-cleanup-queue"; +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"; @@ -979,6 +983,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); @@ -988,21 +993,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, @@ -1864,52 +1854,6 @@ export const registerRoutes = async ( licenseService }); - const certificateAuthorityQueue = certificateAuthorityQueueFactory({ - certificateAuthorityCrlDAL, - certificateAuthorityDAL, - certificateAuthoritySecretDAL, - certificateDAL, - projectDAL, - kmsService, - queueService, - pkiSubscriberDAL, - certificateBodyDAL, - certificateSecretDAL, - externalCertificateAuthorityDAL, - keyStore, - appConnectionDAL, - appConnectionService - }); - - const internalCertificateAuthorityService = internalCertificateAuthorityServiceFactory({ - certificateAuthorityDAL, - certificateAuthorityCertDAL, - certificateAuthoritySecretDAL, - certificateAuthorityCrlDAL, - certificateTemplateDAL, - certificateAuthorityQueue, - certificateDAL, - certificateBodyDAL, - certificateSecretDAL, - pkiCollectionDAL, - pkiCollectionItemDAL, - projectDAL, - internalCertificateAuthorityDAL, - kmsService, - permissionService - }); - - const certificateEstService = certificateEstServiceFactory({ - internalCertificateAuthorityService, - certificateTemplateService, - certificateTemplateDAL, - certificateAuthorityCertDAL, - certificateAuthorityDAL, - projectDAL, - kmsService, - licenseService - }); - const kmipService = kmipServiceFactory({ kmipClientDAL, permissionService, @@ -1952,6 +1896,79 @@ export const registerRoutes = async ( gatewayV2Service }); + const pkiSyncQueue = pkiSyncQueueFactory({ + queueService, + kmsService, + appConnectionDAL, + keyStore, + pkiSyncDAL, + auditLogService, + projectDAL, + licenseService, + certificateDAL, + certificateBodyDAL, + certificateSecretDAL, + certificateAuthorityDAL, + certificateAuthorityCertDAL + }); + + const pkiSyncCleanup = pkiSyncCleanupQueueServiceFactory({ + queueService, + pkiSyncDAL, + pkiSyncQueue + }); + + const internalCaFns = InternalCertificateAuthorityFns({ + certificateAuthorityDAL, + certificateAuthorityCertDAL, + certificateAuthoritySecretDAL, + certificateAuthorityCrlDAL, + certificateDAL, + certificateBodyDAL, + certificateSecretDAL, + projectDAL, + kmsService, + pkiSyncDAL, + pkiSyncQueue + }); + + const certificateAuthorityQueue = certificateAuthorityQueueFactory({ + certificateAuthorityCrlDAL, + certificateAuthorityDAL, + certificateAuthoritySecretDAL, + certificateDAL, + projectDAL, + kmsService, + queueService, + pkiSubscriberDAL, + certificateBodyDAL, + certificateSecretDAL, + externalCertificateAuthorityDAL, + keyStore, + appConnectionDAL, + appConnectionService, + pkiSyncDAL, + pkiSyncQueue + }); + + const internalCertificateAuthorityService = internalCertificateAuthorityServiceFactory({ + certificateAuthorityDAL, + certificateAuthorityCertDAL, + certificateAuthoritySecretDAL, + certificateAuthorityCrlDAL, + certificateTemplateDAL, + certificateAuthorityQueue, + certificateDAL, + certificateBodyDAL, + certificateSecretDAL, + pkiCollectionDAL, + pkiCollectionItemDAL, + projectDAL, + internalCertificateAuthorityDAL, + kmsService, + permissionService + }); + const certificateAuthorityService = certificateAuthorityServiceFactory({ certificateAuthorityDAL, permissionService, @@ -1964,19 +1981,20 @@ export const registerRoutes = async ( certificateSecretDAL, kmsService, pkiSubscriberDAL, - projectDAL + projectDAL, + pkiSyncDAL, + pkiSyncQueue }); - const internalCaFns = InternalCertificateAuthorityFns({ - certificateAuthorityDAL, + const certificateEstService = certificateEstServiceFactory({ + internalCertificateAuthorityService, + certificateTemplateService, + certificateTemplateDAL, certificateAuthorityCertDAL, - certificateAuthoritySecretDAL, - certificateAuthorityCrlDAL, - certificateDAL, - certificateBodyDAL, - certificateSecretDAL, + certificateAuthorityDAL, projectDAL, - kmsService + kmsService, + licenseService }); const pkiSubscriberQueue = pkiSubscriberQueueServiceFactory({ @@ -1989,6 +2007,23 @@ export const registerRoutes = async ( internalCaFns }); + const certificateService = certificateServiceFactory({ + certificateDAL, + certificateBodyDAL, + certificateSecretDAL, + certificateAuthorityDAL, + certificateAuthorityCertDAL, + certificateAuthorityCrlDAL, + certificateAuthoritySecretDAL, + projectDAL, + kmsService, + permissionService, + pkiCollectionDAL, + pkiCollectionItemDAL, + pkiSyncDAL, + pkiSyncQueue + }); + const pkiSubscriberService = pkiSubscriberServiceFactory({ pkiSubscriberDAL, certificateAuthorityDAL, @@ -2002,7 +2037,18 @@ export const registerRoutes = async ( kmsService, permissionService, certificateAuthorityQueue, - internalCaFns + internalCaFns, + pkiSyncDAL, + pkiSyncQueue + }); + + const pkiSyncService = pkiSyncServiceFactory({ + pkiSyncDAL, + pkiSubscriberDAL, + appConnectionService, + permissionService, + licenseService, + pkiSyncQueue }); const pkiTemplateService = pkiTemplatesServiceFactory({ @@ -2067,6 +2113,7 @@ export const registerRoutes = async ( await telemetryQueue.startTelemetryCheck(); await telemetryQueue.startAggregatedEventsJob(); await dailyResourceCleanUp.init(); + await pkiSyncCleanup.init(); await dailyReminderQueueService.startDailyRemindersJob(); await dailyReminderQueueService.startSecretReminderMigrationJob(); await dailyExpiringPkiItemAlert.startSendingAlerts(); @@ -2150,6 +2197,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 4fb07aeac..0332d27a9 100644 --- a/backend/src/server/routes/v1/index.ts +++ b/backend/src/server/routes/v1/index.ts @@ -48,6 +48,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 { PKI_SYNC_REGISTER_ROUTER_MAP, registerPkiSyncRouter } from "./pki-sync-routers"; import { registerProjectEnvRouter } from "./project-env-router"; import { registerProjectKeyRouter } from "./project-key-router"; import { registerProjectMembershipRouter } from "./project-membership-router"; @@ -147,6 +148,15 @@ export const registerV1Routes = async (server: FastifyZodProvider) => { await pkiRouter.register(registerPkiAlertRouter, { prefix: "/alerts" }); await pkiRouter.register(registerPkiCollectionRouter, { prefix: "/collections" }); await pkiRouter.register(registerPkiSubscriberRouter, { prefix: "/subscribers" }); + await pkiRouter.register( + async (pkiSyncRouter) => { + await pkiSyncRouter.register(registerPkiSyncRouter); + for await (const [destination, router] of Object.entries(PKI_SYNC_REGISTER_ROUTER_MAP)) { + await pkiSyncRouter.register(router, { prefix: `/${destination}` }); + } + }, + { prefix: "/syncs" } + ); }, { prefix: "/pki" } ); diff --git a/backend/src/server/routes/v1/pki-sync-routers/azure-key-vault-pki-sync-router.ts b/backend/src/server/routes/v1/pki-sync-routers/azure-key-vault-pki-sync-router.ts new file mode 100644 index 000000000..5dbd89e45 --- /dev/null +++ b/backend/src/server/routes/v1/pki-sync-routers/azure-key-vault-pki-sync-router.ts @@ -0,0 +1,22 @@ +import { + AZURE_KEY_VAULT_PKI_SYNC_LIST_OPTION, + AzureKeyVaultPkiSyncSchema, + CreateAzureKeyVaultPkiSyncSchema, + UpdateAzureKeyVaultPkiSyncSchema +} from "@app/services/pki-sync/azure-key-vault"; +import { PkiSync } from "@app/services/pki-sync/pki-sync-enums"; + +import { registerSyncPkiEndpoints } from "./pki-sync-endpoints"; + +export const registerAzureKeyVaultPkiSyncRouter = async (server: FastifyZodProvider) => + registerSyncPkiEndpoints({ + destination: PkiSync.AzureKeyVault, + server, + responseSchema: AzureKeyVaultPkiSyncSchema, + createSchema: CreateAzureKeyVaultPkiSyncSchema, + updateSchema: UpdateAzureKeyVaultPkiSyncSchema, + syncOptions: { + canImportCertificates: AZURE_KEY_VAULT_PKI_SYNC_LIST_OPTION.canImportCertificates, + canRemoveCertificates: AZURE_KEY_VAULT_PKI_SYNC_LIST_OPTION.canRemoveCertificates + } + }); diff --git a/backend/src/server/routes/v1/pki-sync-routers/index.ts b/backend/src/server/routes/v1/pki-sync-routers/index.ts new file mode 100644 index 000000000..326b650e9 --- /dev/null +++ b/backend/src/server/routes/v1/pki-sync-routers/index.ts @@ -0,0 +1,9 @@ +import { PkiSync } from "@app/services/pki-sync/pki-sync-enums"; + +import { registerAzureKeyVaultPkiSyncRouter } from "./azure-key-vault-pki-sync-router"; + +export * from "./pki-sync-router"; + +export const PKI_SYNC_REGISTER_ROUTER_MAP: Record Promise> = { + [PkiSync.AzureKeyVault]: registerAzureKeyVaultPkiSyncRouter +}; diff --git a/backend/src/server/routes/v1/pki-sync-routers/pki-sync-endpoints.ts b/backend/src/server/routes/v1/pki-sync-routers/pki-sync-endpoints.ts new file mode 100644 index 000000000..3f803a1e0 --- /dev/null +++ b/backend/src/server/routes/v1/pki-sync-routers/pki-sync-endpoints.ts @@ -0,0 +1,341 @@ +import { z } from "zod"; + +import { EventType } from "@app/ee/services/audit-log/audit-log-types"; +import { ApiDocsTags } from "@app/lib/api-docs"; +import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; +import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; +import { AuthMode } from "@app/services/auth/auth-type"; +import { PkiSync } from "@app/services/pki-sync/pki-sync-enums"; +import { PKI_SYNC_NAME_MAP } from "@app/services/pki-sync/pki-sync-maps"; + +export const registerSyncPkiEndpoints = ({ + server, + destination, + createSchema, + updateSchema, + responseSchema, + syncOptions +}: { + destination: PkiSync; + server: FastifyZodProvider; + createSchema: z.ZodType<{ + name: string; + projectId: string; + connectionId: string; + destinationConfig: Record; + syncOptions?: Record; + description?: string; + isAutoSyncEnabled?: boolean; + subscriberId?: string; + }>; + updateSchema: z.ZodType<{ + connectionId?: string; + name?: string; + destinationConfig?: Record; + syncOptions?: Record; + description?: string; + isAutoSyncEnabled?: boolean; + subscriberId?: string; + }>; + responseSchema: z.ZodTypeAny; + syncOptions: { + canImportCertificates: boolean; + canRemoveCertificates: boolean; + }; +}) => { + const destinationName = PKI_SYNC_NAME_MAP[destination]; + + server.route({ + method: "GET", + url: `/`, + config: { + rateLimit: readLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.PkiSyncs], + description: `List the ${destinationName} PKI Syncs for the specified project.`, + querystring: z.object({ + projectId: z.string().trim().min(1, "Project ID required") + }), + response: { + 200: z.object({ pkiSyncs: responseSchema.array() }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const { + query: { projectId } + } = req; + + const pkiSyncs = await server.services.pkiSync.listPkiSyncsByProjectId({ projectId }, req.permission); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId, + event: { + type: EventType.GET_PKI_SYNCS, + metadata: { + projectId + } + } + }); + + return { pkiSyncs }; + } + }); + + server.route({ + method: "GET", + url: "/:pkiSyncId", + config: { + rateLimit: readLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.PkiSyncs], + description: `Get the specified ${destinationName} PKI Sync by ID.`, + params: z.object({ + pkiSyncId: z.string() + }), + response: { + 200: responseSchema + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const { pkiSyncId } = req.params; + + const pkiSync = await server.services.pkiSync.findPkiSyncById({ id: pkiSyncId }, req.permission); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: pkiSync.projectId, + event: { + type: EventType.GET_PKI_SYNC, + metadata: { + syncId: pkiSyncId, + destination + } + } + }); + + return pkiSync; + } + }); + + server.route({ + method: "POST", + url: "/", + config: { + rateLimit: writeLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.PkiSyncs], + description: `Create a ${destinationName} PKI Sync for the specified project.`, + body: createSchema, + response: { + 200: responseSchema + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const pkiSync = await server.services.pkiSync.createPkiSync({ ...req.body, destination }, req.permission); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: pkiSync.projectId, + event: { + type: EventType.CREATE_PKI_SYNC, + metadata: { + pkiSyncId: pkiSync.id, + name: pkiSync.name, + destination + } + } + }); + + return pkiSync; + } + }); + + server.route({ + method: "PATCH", + url: "/:pkiSyncId", + config: { + rateLimit: writeLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.PkiSyncs], + description: `Update the specified ${destinationName} PKI Sync.`, + params: z.object({ + pkiSyncId: z.string() + }), + body: updateSchema, + response: { + 200: responseSchema + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const { pkiSyncId } = req.params; + + const pkiSync = await server.services.pkiSync.updatePkiSync({ ...req.body, id: pkiSyncId }, req.permission); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: pkiSync.projectId, + event: { + type: EventType.UPDATE_PKI_SYNC, + metadata: { + pkiSyncId, + name: pkiSync.name + } + } + }); + + return pkiSync; + } + }); + + server.route({ + method: "DELETE", + url: `/:pkiSyncId`, + config: { + rateLimit: writeLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.PkiSyncs], + description: `Delete the specified ${destinationName} PKI Sync.`, + params: z.object({ + pkiSyncId: z.string() + }), + response: { + 200: responseSchema + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const { pkiSyncId } = req.params; + + const pkiSync = await server.services.pkiSync.deletePkiSync({ id: pkiSyncId }, req.permission); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: pkiSync.projectId, + event: { + type: EventType.DELETE_PKI_SYNC, + metadata: { + pkiSyncId, + name: pkiSync.name, + destination: pkiSync.destination + } + } + }); + + return pkiSync; + } + }); + + server.route({ + method: "POST", + url: "/:pkiSyncId/sync", + config: { + rateLimit: writeLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.PkiSyncs], + description: `Trigger a sync for the specified ${destinationName} PKI Sync.`, + params: z.object({ + pkiSyncId: z.string() + }), + response: { + 200: z.object({ message: z.string() }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const { pkiSyncId } = req.params; + + const result = await server.services.pkiSync.triggerPkiSyncSyncCertificatesById( + { + id: pkiSyncId + }, + req.permission + ); + + return result; + } + }); + + // Only register import route if the destination supports it + if (syncOptions.canImportCertificates) { + server.route({ + method: "POST", + url: "/:pkiSyncId/import", + config: { + rateLimit: writeLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.PkiSyncs], + description: `Import certificates from the specified ${destinationName} PKI Sync destination.`, + params: z.object({ + pkiSyncId: z.string() + }), + response: { + 200: z.object({ message: z.string() }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const { pkiSyncId } = req.params; + + const result = await server.services.pkiSync.triggerPkiSyncImportCertificatesById( + { + id: pkiSyncId + }, + req.permission + ); + + return result; + } + }); + } + + server.route({ + method: "POST", + url: "/:pkiSyncId/remove-certificates", + config: { + rateLimit: writeLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.PkiSyncs], + description: `Remove certificates from the specified ${destinationName} PKI Sync destination.`, + params: z.object({ + pkiSyncId: z.string() + }), + response: { + 200: z.object({ message: z.string() }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const { pkiSyncId } = req.params; + + const result = await server.services.pkiSync.triggerPkiSyncRemoveCertificatesById( + { + id: pkiSyncId + }, + req.permission + ); + + return result; + } + }); +}; diff --git a/backend/src/server/routes/v1/pki-sync-routers/pki-sync-router.ts b/backend/src/server/routes/v1/pki-sync-routers/pki-sync-router.ts new file mode 100644 index 000000000..158d9e4dc --- /dev/null +++ b/backend/src/server/routes/v1/pki-sync-routers/pki-sync-router.ts @@ -0,0 +1,182 @@ +import { z } from "zod"; + +import { EventType } from "@app/ee/services/audit-log/audit-log-types"; +import { ApiDocsTags } from "@app/lib/api-docs"; +import { readLimit } from "@app/server/config/rateLimiter"; +import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; +import { AuthMode } from "@app/services/auth/auth-type"; +import { PkiSync } from "@app/services/pki-sync/pki-sync-enums"; + +const PkiSyncSchema = z.object({ + id: z.string().uuid(), + 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().uuid(), + subscriberId: z.string().uuid().nullable().optional(), + connectionId: z.string().uuid(), + createdAt: z.date(), + updatedAt: z.date(), + // Sync status fields + syncStatus: z.string().nullable().optional(), + lastSyncJobId: z.string().nullable().optional(), + lastSyncMessage: z.string().nullable().optional(), + lastSyncedAt: z.date().nullable().optional(), + // Import status fields + importStatus: z.string().nullable().optional(), + lastImportJobId: z.string().nullable().optional(), + lastImportMessage: z.string().nullable().optional(), + lastImportedAt: z.date().nullable().optional(), + // Remove status fields + removeStatus: z.string().nullable().optional(), + lastRemoveJobId: z.string().nullable().optional(), + lastRemoveMessage: z.string().nullable().optional(), + lastRemovedAt: z.date().nullable().optional(), + // App connection info + appConnectionName: z.string(), + appConnectionApp: z.string(), + connection: z.object({ + id: z.string(), + name: z.string(), + app: z.string(), + encryptedCredentials: z.unknown().nullable(), + orgId: z.string().uuid(), + projectId: z.string().uuid().nullable().optional(), + method: z.string(), + description: z.string().nullable().optional(), + version: z.number(), + gatewayId: z.string().uuid().nullable().optional(), + createdAt: z.date(), + updatedAt: z.date(), + isPlatformManagedCredentials: z.boolean().nullable().optional() + }), + subscriber: z + .object({ + id: z.string(), + name: z.string() + }) + .nullable() + .optional() +}); + +const PkiSyncOptionsSchema = z.object({ + name: z.string(), + connection: z.nativeEnum(AppConnection), + destination: z.nativeEnum(PkiSync), + canImportCertificates: z.boolean(), + canRemoveCertificates: z.boolean(), + defaultCertificateNameSchema: z.string().optional(), + forbiddenCharacters: z.string().optional(), + allowedCharacterPattern: z.string().optional(), + maxCertificateNameLength: z.number().optional(), + minCertificateNameLength: z.number().optional() +}); + +export const registerPkiSyncRouter = async (server: FastifyZodProvider) => { + server.route({ + method: "GET", + url: "/options", + config: { + rateLimit: readLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.PkiSyncs], + description: "List the available PKI Sync Options.", + response: { + 200: z.object({ + pkiSyncOptions: PkiSyncOptionsSchema.array() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: () => { + const pkiSyncOptions = server.services.pkiSync.getPkiSyncOptions(); + return { pkiSyncOptions }; + } + }); + + server.route({ + method: "GET", + url: "/", + config: { + rateLimit: readLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.PkiSyncs], + description: "List all the PKI Syncs for the specified project.", + querystring: z.object({ + projectId: z.string().trim().min(1) + }), + response: { + 200: z.object({ pkiSyncs: PkiSyncSchema.array() }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const { + query: { projectId }, + permission + } = req; + + const pkiSyncs = await server.services.pkiSync.listPkiSyncsByProjectId({ projectId }, permission); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId, + event: { + type: EventType.GET_PKI_SYNCS, + metadata: { + projectId + } + } + }); + + return { pkiSyncs }; + } + }); + + server.route({ + method: "GET", + url: "/:pkiSyncId", + config: { + rateLimit: readLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.PkiSyncs], + description: "Get a PKI Sync by ID.", + params: z.object({ + pkiSyncId: z.string() + }), + response: { + 200: PkiSyncSchema + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const { pkiSyncId } = req.params; + + const pkiSync = await server.services.pkiSync.findPkiSyncById({ id: pkiSyncId }, req.permission); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: pkiSync.projectId, + event: { + type: EventType.GET_PKI_SYNC, + metadata: { + syncId: pkiSyncId, + destination: pkiSync.destination + } + } + }); + + return pkiSync; + } + }); +}; diff --git a/backend/src/services/app-connection/app-connection-fns.ts b/backend/src/services/app-connection/app-connection-fns.ts index f88b5a357..8c8f01af7 100644 --- a/backend/src/services/app-connection/app-connection-fns.ts +++ b/backend/src/services/app-connection/app-connection-fns.ts @@ -150,7 +150,12 @@ const SECRET_SCANNING_APP_CONNECTION_MAP = Object.fromEntries( ); // scott: ideally this would be derived from a utilized map like the above -const PKI_APP_CONNECTIONS = [AppConnection.AWS, AppConnection.Cloudflare, AppConnection.AzureADCS]; +const PKI_APP_CONNECTIONS = [ + AppConnection.AWS, + AppConnection.Cloudflare, + AppConnection.AzureADCS, + AppConnection.AzureKeyVault +]; export const listAppConnectionOptions = (projectType?: ProjectType) => { return [ diff --git a/backend/src/services/certificate-authority/acme/acme-certificate-authority-fns.ts b/backend/src/services/certificate-authority/acme/acme-certificate-authority-fns.ts index b725e5584..521092497 100644 --- a/backend/src/services/certificate-authority/acme/acme-certificate-authority-fns.ts +++ b/backend/src/services/certificate-authority/acme/acme-certificate-authority-fns.ts @@ -23,6 +23,9 @@ import { } from "@app/services/certificate/certificate-types"; 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 { triggerAutoSyncForSubscriber } from "@app/services/pki-sync/pki-sync-utils"; +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"; @@ -56,6 +59,8 @@ type TAcmeCertificateAuthorityFnsDeps = { "encryptWithKmsKey" | "generateKmsKey" | "createCipherPairWithDataKey" | "decryptWithKmsKey" >; pkiSubscriberDAL: Pick; + pkiSyncDAL: Pick; + pkiSyncQueue: Pick; projectDAL: Pick; }; @@ -109,7 +114,9 @@ export const AcmeCertificateAuthorityFns = ({ certificateSecretDAL, kmsService, projectDAL, - pkiSubscriberDAL + pkiSubscriberDAL, + pkiSyncDAL, + pkiSyncQueue }: TAcmeCertificateAuthorityFnsDeps) => { const createCertificateAuthority = async ({ name, @@ -524,6 +531,8 @@ export const AcmeCertificateAuthorityFns = ({ tx ); }); + + await triggerAutoSyncForSubscriber(subscriber.id, { pkiSyncDAL, pkiSyncQueue }); }; return { diff --git a/backend/src/services/certificate-authority/azure-ad-cs/azure-ad-cs-certificate-authority-fns.ts b/backend/src/services/certificate-authority/azure-ad-cs/azure-ad-cs-certificate-authority-fns.ts index 25c5590eb..69feed40e 100644 --- a/backend/src/services/certificate-authority/azure-ad-cs/azure-ad-cs-certificate-authority-fns.ts +++ b/backend/src/services/certificate-authority/azure-ad-cs/azure-ad-cs-certificate-authority-fns.ts @@ -26,6 +26,9 @@ import { import { TKmsServiceFactory } from "@app/services/kms/kms-service"; import { TPkiSubscriberDALFactory } from "@app/services/pki-subscriber/pki-subscriber-dal"; import { TPkiSubscriberProperties } from "@app/services/pki-subscriber/pki-subscriber-types"; +import { TPkiSyncDALFactory } from "@app/services/pki-sync/pki-sync-dal"; +import { triggerAutoSyncForSubscriber } from "@app/services/pki-sync/pki-sync-utils"; +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"; @@ -55,6 +58,8 @@ type TAzureAdCsCertificateAuthorityFnsDeps = { "encryptWithKmsKey" | "generateKmsKey" | "createCipherPairWithDataKey" | "decryptWithKmsKey" >; pkiSubscriberDAL: Pick; + pkiSyncDAL: Pick; + pkiSyncQueue: Pick; projectDAL: Pick; }; @@ -584,7 +589,9 @@ export const AzureAdCsCertificateAuthorityFns = ({ certificateSecretDAL, kmsService, projectDAL, - pkiSubscriberDAL + pkiSubscriberDAL, + pkiSyncDAL, + pkiSyncQueue }: TAzureAdCsCertificateAuthorityFnsDeps) => { const createCertificateAuthority = async ({ name, @@ -1024,6 +1031,8 @@ export const AzureAdCsCertificateAuthorityFns = ({ ); }); + await triggerAutoSyncForSubscriber(subscriber.id, { pkiSyncDAL, pkiSyncQueue }); + return { certificate: certificatePem, certificateChain: certificateChainPem, diff --git a/backend/src/services/certificate-authority/certificate-authority-queue.ts b/backend/src/services/certificate-authority/certificate-authority-queue.ts index 21f7b71e6..81aab337d 100644 --- a/backend/src/services/certificate-authority/certificate-authority-queue.ts +++ b/backend/src/services/certificate-authority/certificate-authority-queue.ts @@ -20,6 +20,8 @@ import { TCertificateBodyDALFactory } from "../certificate/certificate-body-dal" import { TCertificateSecretDALFactory } from "../certificate/certificate-secret-dal"; import { TPkiSubscriberDALFactory } from "../pki-subscriber/pki-subscriber-dal"; import { SubscriberOperationStatus } from "../pki-subscriber/pki-subscriber-types"; +import { TPkiSyncDALFactory } from "../pki-sync/pki-sync-dal"; +import { TPkiSyncQueueFactory } from "../pki-sync/pki-sync-queue"; import { AcmeCertificateAuthorityFns } from "./acme/acme-certificate-authority-fns"; import { AzureAdCsCertificateAuthorityFns } from "./azure-ad-cs/azure-ad-cs-certificate-authority-fns"; import { TCertificateAuthorityDALFactory } from "./certificate-authority-dal"; @@ -50,6 +52,8 @@ type TCertificateAuthorityQueueFactoryDep = { certificateSecretDAL: Pick; queueService: TQueueServiceFactory; pkiSubscriberDAL: Pick; + pkiSyncDAL: Pick; + pkiSyncQueue: Pick; }; export type TCertificateAuthorityQueueFactory = ReturnType; @@ -68,7 +72,9 @@ export const certificateAuthorityQueueFactory = ({ externalCertificateAuthorityDAL, certificateBodyDAL, certificateSecretDAL, - pkiSubscriberDAL + pkiSubscriberDAL, + pkiSyncDAL, + pkiSyncQueue }: TCertificateAuthorityQueueFactoryDep) => { const acmeFns = AcmeCertificateAuthorityFns({ appConnectionDAL, @@ -80,7 +86,9 @@ export const certificateAuthorityQueueFactory = ({ certificateSecretDAL, kmsService, pkiSubscriberDAL, - projectDAL + projectDAL, + pkiSyncDAL, + pkiSyncQueue }); const azureAdCsFns = AzureAdCsCertificateAuthorityFns({ @@ -93,7 +101,9 @@ export const certificateAuthorityQueueFactory = ({ certificateSecretDAL, kmsService, pkiSubscriberDAL, - projectDAL + projectDAL, + pkiSyncDAL, + pkiSyncQueue }); // TODO 1: auto-periodic rotation diff --git a/backend/src/services/certificate-authority/certificate-authority-service.ts b/backend/src/services/certificate-authority/certificate-authority-service.ts index 02c5a488a..ed27f7571 100644 --- a/backend/src/services/certificate-authority/certificate-authority-service.ts +++ b/backend/src/services/certificate-authority/certificate-authority-service.ts @@ -13,6 +13,8 @@ import { TCertificateDALFactory } from "../certificate/certificate-dal"; import { TCertificateSecretDALFactory } from "../certificate/certificate-secret-dal"; import { TKmsServiceFactory } from "../kms/kms-service"; import { TPkiSubscriberDALFactory } from "../pki-subscriber/pki-subscriber-dal"; +import { TPkiSyncDALFactory } from "../pki-sync/pki-sync-dal"; +import { TPkiSyncQueueFactory } from "../pki-sync/pki-sync-queue"; import { TProjectDALFactory } from "../project/project-dal"; import { AcmeCertificateAuthorityFns, @@ -68,6 +70,8 @@ type TCertificateAuthorityServiceFactoryDep = { "encryptWithKmsKey" | "generateKmsKey" | "createCipherPairWithDataKey" | "decryptWithKmsKey" >; pkiSubscriberDAL: Pick; + pkiSyncDAL: Pick; + pkiSyncQueue: Pick; }; export type TCertificateAuthorityServiceFactory = ReturnType; @@ -84,7 +88,9 @@ export const certificateAuthorityServiceFactory = ({ certificateBodyDAL, certificateSecretDAL, kmsService, - pkiSubscriberDAL + pkiSubscriberDAL, + pkiSyncDAL, + pkiSyncQueue }: TCertificateAuthorityServiceFactoryDep) => { const acmeFns = AcmeCertificateAuthorityFns({ appConnectionDAL, @@ -96,7 +102,9 @@ export const certificateAuthorityServiceFactory = ({ certificateSecretDAL, kmsService, pkiSubscriberDAL, - projectDAL + projectDAL, + pkiSyncDAL, + pkiSyncQueue }); const azureAdCsFns = AzureAdCsCertificateAuthorityFns({ @@ -109,7 +117,9 @@ export const certificateAuthorityServiceFactory = ({ certificateSecretDAL, kmsService, pkiSubscriberDAL, - projectDAL + projectDAL, + pkiSyncDAL, + pkiSyncQueue }); const createCertificateAuthority = async ( diff --git a/backend/src/services/certificate-authority/internal/internal-certificate-authority-fns.ts b/backend/src/services/certificate-authority/internal/internal-certificate-authority-fns.ts index 9f1a5e5c2..f1dc372b1 100644 --- a/backend/src/services/certificate-authority/internal/internal-certificate-authority-fns.ts +++ b/backend/src/services/certificate-authority/internal/internal-certificate-authority-fns.ts @@ -19,6 +19,9 @@ import { TAltNameMapping } from "@app/services/certificate/certificate-types"; import { TKmsServiceFactory } from "@app/services/kms/kms-service"; +import { TPkiSyncDALFactory } from "@app/services/pki-sync/pki-sync-dal"; +import { triggerAutoSyncForSubscriber } from "@app/services/pki-sync/pki-sync-utils"; +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"; @@ -51,6 +54,8 @@ type TInternalCertificateAuthorityFnsDeps = { certificateDAL: Pick; certificateBodyDAL: Pick; certificateSecretDAL: Pick; + pkiSyncDAL: Pick; + pkiSyncQueue: Pick; }; export const InternalCertificateAuthorityFns = ({ @@ -62,7 +67,9 @@ export const InternalCertificateAuthorityFns = ({ certificateAuthorityCrlDAL, certificateDAL, certificateBodyDAL, - certificateSecretDAL + certificateSecretDAL, + pkiSyncDAL, + pkiSyncQueue }: TInternalCertificateAuthorityFnsDeps) => { const issueCertificate = async ( subscriber: TPkiSubscribers, @@ -251,6 +258,8 @@ export const InternalCertificateAuthorityFns = ({ ); }); + await triggerAutoSyncForSubscriber(subscriber.id, { pkiSyncDAL, pkiSyncQueue }); + return { certificate: leafCert.toString("pem"), certificateChain: certificateChainPem, diff --git a/backend/src/services/certificate/certificate-dal.ts b/backend/src/services/certificate/certificate-dal.ts index 9db473236..88808bd63 100644 --- a/backend/src/services/certificate/certificate-dal.ts +++ b/backend/src/services/certificate/certificate-dal.ts @@ -1,5 +1,5 @@ import { TDbClient } from "@app/db"; -import { TableName } from "@app/db/schemas"; +import { TableName, TCertificates } from "@app/db/schemas"; import { DatabaseError } from "@app/lib/errors"; import { ormify } from "@app/lib/knex"; @@ -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, @@ -79,10 +93,33 @@ export const certificateDALFactory = (db: TDbClient) => { } }; + const findExpiredSyncedCertificates = async (): Promise => { + try { + const yesterday = new Date(); + yesterday.setDate(yesterday.getDate() - 1); + yesterday.setHours(0, 0, 0, 0); + + const today = new Date(); + today.setHours(0, 0, 0, 0); + + const certs = await db + .replicaNode()(TableName.Certificate) + .where("notAfter", ">=", yesterday) + .where("notAfter", "<", today) + .whereNotNull("pkiSubscriberId"); + + return certs; + } catch (error) { + throw new DatabaseError({ error, name: "Find expired synced certificates" }); + } + }; + return { ...certificateOrm, countCertificatesInProject, countCertificatesForPkiSubscriber, - findLatestActiveCertForSubscriber + findLatestActiveCertForSubscriber, + findAllActiveCertsForSubscriber, + findExpiredSyncedCertificates }; }; diff --git a/backend/src/services/certificate/certificate-service.ts b/backend/src/services/certificate/certificate-service.ts index 7adc60d52..91731c387 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"; @@ -20,6 +21,9 @@ 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 { triggerAutoSyncForSubscriber } from "@app/services/pki-sync/pki-sync-utils"; 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,7 +75,9 @@ export const certificateServiceFactory = ({ pkiCollectionItemDAL, projectDAL, kmsService, - permissionService + permissionService, + pkiSyncDAL, + pkiSyncQueue }: TCertificateServiceFactoryDep) => { /** * Return details for certificate with serial number [serialNumber] @@ -158,6 +166,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, { pkiSyncDAL, pkiSyncQueue }); + } + return { deletedCert }; @@ -222,6 +235,11 @@ export const certificateServiceFactory = ({ } ); + // Trigger auto sync for PKI syncs connected to this certificate's subscriber + if (cert.pkiSubscriberId) { + await triggerAutoSyncForSubscriber(cert.pkiSubscriberId, { pkiSyncDAL, pkiSyncQueue }); + } + // Note: External CA revocation handling would go here for supported CA types // Currently, only internal CAs and ACME CAs support revocation diff --git a/backend/src/services/connection-queue/connection-queue-fns.ts b/backend/src/services/connection-queue/connection-queue-fns.ts new file mode 100644 index 000000000..8c0fba4a2 --- /dev/null +++ b/backend/src/services/connection-queue/connection-queue-fns.ts @@ -0,0 +1,129 @@ +/* eslint-disable no-await-in-loop */ +import { AxiosError } from "axios"; + +import { logger } from "@app/lib/logger"; + +export type RateLimitConfig = { + MAX_CONCURRENT_REQUESTS: number; + BASE_DELAY: number; + MAX_DELAY: number; + MAX_RETRIES: number; + RATE_LIMIT_STATUS_CODES: number[]; +}; + +export type RateLimitContext = { + operation: string; + identifier?: string; + syncId: string; +}; + +export type ConcurrencyContext = { + operation: string; + syncId: string; +}; + +export const sleep = (ms: number): Promise => + new Promise((resolve) => { + setTimeout(resolve, ms); + }); + +export const createRateLimitErrorChecker = + (config: RateLimitConfig) => + (error: unknown): boolean => { + if (error instanceof AxiosError) { + return ( + config.RATE_LIMIT_STATUS_CODES.includes(error.response?.status || 0) || + error.message.toLowerCase().includes("rate limit") || + error.message.toLowerCase().includes("throttl") + ); + } + return false; + }; + +export const createRateLimitRetry = + (config: RateLimitConfig, isRateLimitError: (error: unknown) => boolean) => + async (fn: () => Promise, context: RateLimitContext, retryCount = 0): Promise => { + try { + return await fn(); + } catch (error) { + if (isRateLimitError(error) && retryCount < config.MAX_RETRIES) { + const delay = Math.min(config.BASE_DELAY * 2 ** retryCount, config.MAX_DELAY); + + logger.warn( + { + syncId: context.syncId, + operation: context.operation, + identifier: context.identifier, + retryCount: retryCount + 1, + delayMs: delay, + error: error instanceof AxiosError ? error.message : String(error) + }, + "Rate limit hit, retrying with exponential backoff" + ); + + await sleep(delay); + return createRateLimitRetry(config, isRateLimitError)(fn, context, retryCount + 1); + } + + throw error; + } + }; + +export const createConcurrencyLimitExecutor = + ( + config: RateLimitConfig, + withRateLimitRetry: (fn: () => Promise, context: RateLimitContext, retryCount?: number) => Promise + ) => + async ( + items: T[], + executor: (item: T) => Promise, + context: ConcurrencyContext, + concurrencyLimit = config.MAX_CONCURRENT_REQUESTS + ): Promise[]> => { + const results: PromiseSettledResult[] = []; + + for (let i = 0; i < items.length; i += concurrencyLimit) { + const batch = items.slice(i, i + concurrencyLimit); + + logger.debug( + { + syncId: context.syncId, + operation: context.operation, + batchStart: i + 1, + batchEnd: Math.min(i + concurrencyLimit, items.length), + totalItems: items.length + }, + "Processing batch with rate limit protection" + ); + + const batchPromises = batch.map((item, batchIndex) => + withRateLimitRetry(() => executor(item), { + operation: context.operation, + identifier: `batch-${i + batchIndex + 1}`, + syncId: context.syncId + }) + ); + + const batchResults = await Promise.allSettled(batchPromises); + results.push(...batchResults); + + if (i + concurrencyLimit < items.length) { + await sleep(100); + } + } + + return results; + }; + +export const createConnectionQueue = (config: RateLimitConfig) => { + const isRateLimitError = createRateLimitErrorChecker(config); + const withRateLimitRetry = createRateLimitRetry(config, isRateLimitError); + const executeWithConcurrencyLimit = createConcurrencyLimitExecutor(config, withRateLimitRetry); + + return { + sleep, + isRateLimitError, + withRateLimitRetry, + executeWithConcurrencyLimit + }; +}; diff --git a/backend/src/services/connection-queue/index.ts b/backend/src/services/connection-queue/index.ts new file mode 100644 index 000000000..7e51d42aa --- /dev/null +++ b/backend/src/services/connection-queue/index.ts @@ -0,0 +1 @@ +export * from "./connection-queue-fns"; diff --git a/backend/src/services/pki-subscriber/pki-subscriber-service.ts b/backend/src/services/pki-subscriber/pki-subscriber-service.ts index 391b5b2a8..4e00acec6 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"; @@ -36,6 +37,9 @@ 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 { triggerAutoSyncForSubscriber } from "@app/services/pki-sync/pki-sync-utils"; +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,7 +102,9 @@ export const pkiSubscriberServiceFactory = ({ kmsService, permissionService, certificateAuthorityQueue, - internalCaFns + internalCaFns, + pkiSyncDAL, + pkiSyncQueue }: TPkiSubscriberServiceFactoryDep) => { const createSubscriber = async ({ name, @@ -413,7 +421,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, { pkiSyncDAL, pkiSyncQueue }); + + return result; } throw new BadRequestError({ message: "CA does not support immediate issuance of certificates" }); @@ -671,6 +684,9 @@ export const pkiSubscriberServiceFactory = ({ return cert; }); + // Trigger auto sync for PKI syncs connected to this subscriber after certificate signing + await triggerAutoSyncForSubscriber(subscriber.id, { pkiSyncDAL, pkiSyncQueue }); + 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-constants.ts b/backend/src/services/pki-sync/azure-key-vault/azure-key-vault-pki-sync-constants.ts new file mode 100644 index 000000000..3c64bc86d --- /dev/null +++ b/backend/src/services/pki-sync/azure-key-vault/azure-key-vault-pki-sync-constants.ts @@ -0,0 +1,52 @@ +import RE2 from "re2"; + +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; +import { PkiSync } from "@app/services/pki-sync/pki-sync-enums"; + +/** + * Azure Key Vault naming constraints for certificates + */ +export const AZURE_KEY_VAULT_CERTIFICATE_NAMING = { + /** + * Regular expression pattern for valid Azure Key Vault certificate names + * Must contain only alphanumeric characters and hyphens (a-z, A-Z, 0-9, -) + * Must be 1-127 characters long + */ + NAME_PATTERN: new RE2("^[a-zA-Z0-9-]{1,127}$"), + + /** + * String of characters that are forbidden in Azure Key Vault certificate names + */ + FORBIDDEN_CHARACTERS: "!@#$%^&*()+=[]{}|\\:;\"'<>,.?/~` _", + + /** + * Maximum length for certificate names in Azure Key Vault + */ + MAX_NAME_LENGTH: 127, + + /** + * Minimum length for certificate names in Azure Key Vault + */ + MIN_NAME_LENGTH: 1, + + /** + * String representation of the allowed character pattern (for UI display) + */ + ALLOWED_CHARACTER_PATTERN: "^[a-zA-Z0-9-]{1,127}$" +} as const; + +/** + * Azure Key Vault PKI Sync list option configuration + */ +export const AZURE_KEY_VAULT_PKI_SYNC_LIST_OPTION = { + name: "Azure Key Vault" as const, + connection: AppConnection.AzureKeyVault, + destination: PkiSync.AzureKeyVault, + canImportCertificates: false, + canRemoveCertificates: true, + defaultCertificateNameSchema: "Infisical-PKI-Sync-{{certificateId}}", + forbiddenCharacters: AZURE_KEY_VAULT_CERTIFICATE_NAMING.FORBIDDEN_CHARACTERS, + allowedCharacterPattern: AZURE_KEY_VAULT_CERTIFICATE_NAMING.ALLOWED_CHARACTER_PATTERN, + maxCertificateNameLength: AZURE_KEY_VAULT_CERTIFICATE_NAMING.MAX_NAME_LENGTH, + minCertificateNameLength: AZURE_KEY_VAULT_CERTIFICATE_NAMING.MIN_NAME_LENGTH +} as const; 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..b423f42db --- /dev/null +++ b/backend/src/services/pki-sync/azure-key-vault/azure-key-vault-pki-sync-fns.ts @@ -0,0 +1,680 @@ +/* eslint-disable no-await-in-loop */ +import { AxiosError } from "axios"; +import * as crypto from "crypto"; + +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 { createConnectionQueue, RateLimitConfig } from "@app/services/connection-queue"; +import { TKmsServiceFactory } from "@app/services/kms/kms-service"; +import { matchesCertificateNameSchema } from "@app/services/pki-sync/pki-sync-fns"; +import { TCertificateMap } from "@app/services/pki-sync/pki-sync-types"; + +import { PkiSyncError } from "../pki-sync-errors"; +import { TPkiSyncWithCredentials } from "../pki-sync-types"; +import { GetAzureKeyVaultCertificate, TAzureKeyVaultPkiSyncConfig } from "./azure-key-vault-pki-sync-types"; + +const AZURE_RATE_LIMIT_CONFIG: RateLimitConfig = { + MAX_CONCURRENT_REQUESTS: 10, + BASE_DELAY: 1000, + MAX_DELAY: 30000, + MAX_RETRIES: 3, + RATE_LIMIT_STATUS_CODES: [429, 503] +}; + +const azureConnectionQueue = createConnectionQueue(AZURE_RATE_LIMIT_CONFIG); + +const { withRateLimitRetry, executeWithConcurrencyLimit } = azureConnectionQueue; + +const extractCertificateNameFromId = (certificateId: string): string => { + return certificateId.substring(certificateId.lastIndexOf("/") + 1); +}; + +const isInfisicalManagedCertificate = (certificateName: string, pkiSync: TPkiSyncWithCredentials): boolean => { + const syncOptions = pkiSync.syncOptions as { certificateNameSchema?: string } | undefined; + const certificateNameSchema = syncOptions?.certificateNameSchema; + + if (certificateNameSchema) { + const environment = "global"; + return matchesCertificateNameSchema(certificateName, environment, certificateNameSchema); + } + + return certificateName.startsWith("Infisical-PKI-Sync-"); +}; + +type TAzureKeyVaultPkiSyncFactoryDeps = { + appConnectionDAL: Pick; + kmsService: Pick; +}; + +const parseCertificateX509Props = (certPem: string) => { + try { + const cert = new crypto.X509Certificate(certPem); + + const { subject } = cert; + + const sans = { + dns_names: [] as string[], + emails: [] as string[], + upns: [] as string[] + }; + + if (cert.subjectAltName) { + const sanEntries = cert.subjectAltName.split(", "); + for (const entry of sanEntries) { + if (entry.startsWith("DNS:")) { + sans.dns_names.push(entry.substring(4)); + } else if (entry.startsWith("email:")) { + sans.emails.push(entry.substring(6)); + } else if (entry.startsWith("othername:UPN:")) { + sans.upns.push(entry.substring(14)); + } + } + } + + return { + subject, + sans + }; + } catch (error) { + logger.warn( + { error: error instanceof Error ? error.message : String(error) }, + "Failed to parse certificate X.509 properties, using empty values" + ); + return { + subject: "", + sans: { + dns_names: [], + emails: [], + upns: [] + } + }; + } +}; + +const parseCertificateKeyProps = (certPem: string) => { + try { + const publicKeyObject = crypto.createPublicKey(certPem); + const keyDetails = publicKeyObject.asymmetricKeyDetails; + + if (!keyDetails) { + if (publicKeyObject.asymmetricKeyType === "rsa") { + const pubKeyStr = publicKeyObject.export({ type: "spki", format: "der" }).toString("hex"); + const estimatedBits = pubKeyStr.length * 4; + + let keySize = 2048; + if (estimatedBits >= 4000) { + keySize = 4096; + } else if (estimatedBits >= 3000) { + keySize = 3072; + } else if (estimatedBits >= 2000) { + keySize = 2048; + } else if (estimatedBits >= 1000) { + keySize = 1024; + } + + return { + kty: "RSA", + key_size: keySize + }; + } + + if (publicKeyObject.asymmetricKeyType === "ec") { + return { + kty: "EC", + curve: "P-256" + }; + } + + return { + kty: "RSA", + key_size: 2048 + }; + } + + if (publicKeyObject.asymmetricKeyType === "rsa") { + const modulusLength = keyDetails.modulusLength || 2048; + return { + kty: "RSA", + key_size: modulusLength + }; + } + + if (publicKeyObject.asymmetricKeyType === "ec") { + const { namedCurve } = keyDetails; + let curveName = "P-256"; + + switch (namedCurve) { + case "prime256v1": + case "secp256r1": + curveName = "P-256"; + break; + case "secp384r1": + curveName = "P-384"; + break; + case "secp521r1": + curveName = "P-521"; + break; + default: + curveName = "P-256"; + } + + return { + kty: "EC", + curve: curveName + }; + } + + const keyType = publicKeyObject.asymmetricKeyType; + if (keyType && !["rsa", "ec"].includes(keyType)) { + throw new Error(`Unsupported certificate key type: ${keyType}. Azure Key Vault only supports RSA and EC keys.`); + } + + logger.warn({ keyType }, "Unable to determine certificate key type, defaulting to RSA 2048"); + return { + kty: "RSA", + key_size: 2048 + }; + } catch (error) { + logger.warn( + { error: error instanceof Error ? error.message : String(error) }, + "Failed to parse certificate key properties, defaulting to RSA 2048" + ); + return { + kty: "RSA", + key_size: 2048 + }; + } +}; + +export const azureKeyVaultPkiSyncFactory = ({ kmsService, appConnectionDAL }: TAzureKeyVaultPkiSyncFactoryDeps) => { + const $getAzureKeyVaultCertificates = async (accessToken: string, vaultBaseUrl: string, syncId = "unknown") => { + const paginateAzureKeyVaultCertificates = async () => { + let result: GetAzureKeyVaultCertificate[] = []; + + let currentUrl = `${vaultBaseUrl}/certificates?api-version=7.4`; + + while (currentUrl) { + const urlToFetch = currentUrl; // Capture current URL to avoid loop function issue + const res = await withRateLimitRetry( + () => + request.get<{ value: GetAzureKeyVaultCertificate[]; nextLink: string }>(urlToFetch, { + headers: { + Authorization: `Bearer ${accessToken}` + } + }), + { operation: "list-certificates", syncId } + ); + + 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((certificate) => extractCertificateNameFromId(certificate.id)); + + // Use rate-limited concurrent execution for fetching certificate details + const certificateResults = await executeWithConcurrencyLimit( + enabledAzureKeyVaultCertificates, + async (getAzureKeyVaultCertificate) => { + const azureKeyVaultCertificate = await request.get( + `${getAzureKeyVaultCertificate.id}?api-version=7.4`, + { + headers: { + Authorization: `Bearer ${accessToken}` + } + } + ); + + 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; + const base64Lines = base64Cert.match(/.{1,64}/g); + if (!base64Lines) { + throw new Error("Failed to format base64 certificate data"); + } + certPem = `-----BEGIN CERTIFICATE-----\n${base64Lines.join("\n")}\n-----END CERTIFICATE-----`; + } catch (error) { + logger.warn( + { + error: error instanceof Error ? error.message : String(error), + certificateId: getAzureKeyVaultCertificate.id + }, + "Failed to convert Azure Key Vault certificate to PEM format, skipping certificate" + ); + certPem = ""; // Skip this certificate if we can't convert it properly + } + } + + return { + ...azureKeyVaultCertificate.data, + key: extractCertificateNameFromId(getAzureKeyVaultCertificate.id), + cert: certPem, + privateKey: "" // Private keys cannot be extracted from Azure Key Vault for security reasons + }; + }, + { operation: "fetch-certificate-details", syncId } + ); + + const successfulCertificates = certificateResults + .filter( + ( + result + ): result is PromiseFulfilledResult< + GetAzureKeyVaultCertificate & { + key: string; + cert: string; + privateKey: string; + } + > => result.status === "fulfilled" + ) + .map((result) => result.value); + + // Log any failures + const failedFetches = certificateResults.filter((result) => result.status === "rejected"); + if (failedFetches.length > 0) { + logger.warn( + { + syncId, + failedCount: failedFetches.length, + totalCount: enabledAzureKeyVaultCertificates.length + }, + "Some certificate details could not be fetched from Azure Key Vault" + ); + } + + const res: Record = successfulCertificates.reduce( + (obj, certificate) => ({ + ...obj, + [certificate.key]: { + cert: certificate.cert, + privateKey: certificate.privateKey + } + }), + {} as Record + ); + + return { + vaultCertificates: res, + disabledAzureKeyVaultCertificateKeys + }; + }; + + const syncCertificates = async (pkiSync: TPkiSyncWithCredentials, certificateMap: TCertificateMap) => { + const { accessToken } = await getAzureConnectionAccessToken(pkiSync.connection.id, appConnectionDAL, kmsService); + + // Cast destination config to Azure Key Vault config + const destinationConfig = pkiSync.destinationConfig as TAzureKeyVaultPkiSyncConfig; + + const { vaultCertificates, disabledAzureKeyVaultCertificateKeys } = await $getAzureKeyVaultCertificates( + accessToken, + destinationConfig.vaultBaseUrl, + pkiSync.id + ); + + const setCertificates: { + key: string; + cert: string; + privateKey: string; + certificateChain?: 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, certificateChain }]) => { + if (disabledAzureKeyVaultCertificateKeys.includes(certName)) { + return; + } + + const existingCert = vaultCertificates[certName]; + const shouldUpdateCert = !existingCert || existingCert.cert !== cert; + + if (shouldUpdateCert) { + setCertificates.push({ + key: certName, + cert, + privateKey, + certificateChain + }); + } + }); + + // Identify expired/removed certificates that need to be cleaned up from Azure Key Vault + // Only remove certificates that were managed by Infisical (match naming schema) + const certificatesToRemove = Object.keys(vaultCertificates).filter( + (vaultCertName) => + isInfisicalManagedCertificate(vaultCertName, pkiSync) && + !activeCertificateNames.includes(vaultCertName) && + !disabledAzureKeyVaultCertificateKeys.includes(vaultCertName) + ); + + // Upload certificates to Azure Key Vault with rate limiting + const uploadResults = await executeWithConcurrencyLimit( + setCertificates, + async ({ key, cert, privateKey, certificateChain }) => { + try { + // Combine private key, certificate, and certificate chain in PEM format for Azure Key Vault + let combinedPem = ""; + + if (privateKey) { + combinedPem = privateKey.trim(); + } + + if (combinedPem) { + combinedPem = `${combinedPem}\n${cert.trim()}`; + } else { + combinedPem = cert.trim(); + } + + if (certificateChain) { + const trimmedChain = certificateChain.trim(); + if (trimmedChain) { + combinedPem = `${combinedPem}\n${trimmedChain}`; + } + } + + // Convert to base64 for Azure Key Vault import + const base64Cert = Buffer.from(combinedPem).toString("base64"); + + // Parse certificate to extract X.509 properties and key properties + const x509Props = parseCertificateX509Props(cert); + const keyProps = parseCertificateKeyProps(cert); + + // Build key_props based on key type + const keyPropsConfig = { + exportable: true, + reuse_key: false, + ...keyProps + }; + + const importData = { + value: base64Cert, + policy: { + key_props: keyPropsConfig, + secret_props: { + contentType: "application/x-pem-file" + }, + x509_props: x509Props + }, + attributes: { + enabled: true, + exportable: true + } + }; + + const response = await request.post( + `${destinationConfig.vaultBaseUrl}/certificates/${encodeURIComponent(key)}/import?api-version=7.4`, + importData, + { + headers: { + Authorization: `Bearer ${accessToken}`, + "Content-Type": "application/json" + } + } + ); + + 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 { 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; + } + }, + { operation: "upload-certificates", syncId: pkiSync.id } + ); + + const results = uploadResults; + 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) { + const removeResults = await executeWithConcurrencyLimit( + certificatesToRemove, + async (certName) => { + try { + await request.delete( + `${destinationConfig.vaultBaseUrl}/certificates/${encodeURIComponent(certName)}?api-version=7.4`, + { + headers: { + Authorization: `Bearer ${accessToken}` + } + } + ); + + 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) { + 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 }; + } + }, + { operation: "remove-certificates", syncId: pkiSync.id } + ); + 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" + ); + } + } + + // Collect detailed information for UI feedback + const details: { + failedUploads?: Array<{ name: string; error: string }>; + failedRemovals?: Array<{ name: string; error: string }>; + skippedCertificates?: Array<{ name: string; reason: string }>; + } = {}; + + // Collect skipped certificate details + if (skippedUploads.length > 0) { + details.skippedCertificates = skippedUploads.map((result) => { + const certificateName = result.status === "fulfilled" ? result.value.key : "unknown"; + return { + name: certificateName, + reason: "Azure Key Vault constraints or certificate already up to date" + }; + }); + } + + // Collect failed upload details + if (failedUploads.length > 0) { + details.failedUploads = failedUploads.map((failure, index) => { + const certificateName = setCertificates[index]?.key || "unknown"; + let errorMessage = "Unknown error"; + + if (failure.status === "rejected") { + errorMessage = (failure.reason as Error)?.message || "Unknown error"; + } + + return { + name: certificateName, + error: errorMessage + }; + }); + + logger.error( + { + syncId: pkiSync.id, + failedUploads: details.failedUploads, + failedCount: failedUploads.length + }, + "Some certificates failed to upload to Azure Key Vault" + ); + } + + // Collect failed removal details + if (failedRemovals > 0) { + const failedRemovalNames = certificatesToRemove.slice(-failedRemovals); + details.failedRemovals = failedRemovalNames.map((certName) => ({ + name: certName, + error: "Failed to remove from Azure Key Vault" + })); + + logger.warn( + { + syncId: pkiSync.id, + failedRemovals: details.failedRemovals, + successfulRemovals: removedCertificates + }, + "Some expired/removed certificates could not be removed from Azure Key Vault" + ); + } + + return { + uploaded: successfulUploads.length, + removed: removedCertificates, + failedRemovals, + skipped: Object.keys(certificateMap).length - setCertificates.length, + details: Object.keys(details).length > 0 ? details : undefined + }; + }; + + const removeCertificates = async (pkiSync: TPkiSyncWithCredentials, certificateNames: string[]) => { + const { accessToken } = await getAzureConnectionAccessToken(pkiSync.connection.id, appConnectionDAL, kmsService); + + // Cast destination config to Azure Key Vault config + const destinationConfig = pkiSync.destinationConfig as TAzureKeyVaultPkiSyncConfig; + + // Only remove certificates that are managed by Infisical (match naming schema) + const infisicalManagedCertNames = certificateNames.filter((certName) => + isInfisicalManagedCertificate(certName, pkiSync) + ); + + const results = await executeWithConcurrencyLimit( + infisicalManagedCertNames, + async (certName) => { + try { + const response = await request.delete( + `${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) { + 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; + } + }, + { operation: "remove-specific-certificates", syncId: pkiSync.id } + ); + const failedRemovals = results.filter((result) => result.status === "rejected"); + + if (failedRemovals.length > 0) { + const failedReasons = failedRemovals.map((failure) => { + if (failure.status === "rejected") { + return (failure.reason 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, + removeCertificates + }; +}; diff --git a/backend/src/services/pki-sync/azure-key-vault/azure-key-vault-pki-sync-schemas.ts b/backend/src/services/pki-sync/azure-key-vault/azure-key-vault-pki-sync-schemas.ts new file mode 100644 index 000000000..ef6347e82 --- /dev/null +++ b/backend/src/services/pki-sync/azure-key-vault/azure-key-vault-pki-sync-schemas.ts @@ -0,0 +1,74 @@ +import RE2 from "re2"; +import { z } from "zod"; + +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; +import { PkiSync } from "@app/services/pki-sync/pki-sync-enums"; +import { PkiSyncSchema } from "@app/services/pki-sync/pki-sync-schemas"; + +import { AZURE_KEY_VAULT_CERTIFICATE_NAMING } from "./azure-key-vault-pki-sync-constants"; + +export const AzureKeyVaultPkiSyncConfigSchema = z.object({ + vaultBaseUrl: z.string().url() +}); + +const AzureKeyVaultPkiSyncOptionsSchema = z.object({ + canImportCertificates: z.boolean().default(false), + canRemoveCertificates: z.boolean().default(true), + certificateNameSchema: z + .string() + .optional() + .refine( + (schema) => { + if (!schema) return true; + + const testName = schema + .replace(new RE2("\\{\\{certificateId\\}\\}", "g"), "") + .replace(new RE2("\\{\\{environment\\}\\}", "g"), ""); + + const hasForbiddenChars = AZURE_KEY_VAULT_CERTIFICATE_NAMING.FORBIDDEN_CHARACTERS.split("").some((char) => + testName.includes(char) + ); + + return AZURE_KEY_VAULT_CERTIFICATE_NAMING.NAME_PATTERN.test(testName) && !hasForbiddenChars; + }, + { + message: + "Certificate name schema must result in names that contain only alphanumeric characters and hyphens (a-z, A-Z, 0-9, -) and be 1-127 characters long when compiled for Azure Key Vault" + } + ) +}); + +export const AzureKeyVaultPkiSyncSchema = PkiSyncSchema.extend({ + destination: z.literal(PkiSync.AzureKeyVault), + destinationConfig: AzureKeyVaultPkiSyncConfigSchema, + syncOptions: AzureKeyVaultPkiSyncOptionsSchema +}); + +export const CreateAzureKeyVaultPkiSyncSchema = z.object({ + name: z.string().trim().min(1).max(64), + description: z.string().optional(), + isAutoSyncEnabled: z.boolean().default(true), + destinationConfig: AzureKeyVaultPkiSyncConfigSchema, + syncOptions: AzureKeyVaultPkiSyncOptionsSchema.optional().default({}), + subscriberId: z.string().optional(), + connectionId: z.string(), + projectId: z.string().trim().min(1) +}); + +export const UpdateAzureKeyVaultPkiSyncSchema = z.object({ + name: z.string().trim().min(1).max(64).optional(), + description: z.string().optional(), + isAutoSyncEnabled: z.boolean().optional(), + destinationConfig: AzureKeyVaultPkiSyncConfigSchema.optional(), + syncOptions: AzureKeyVaultPkiSyncOptionsSchema.optional(), + subscriberId: z.string().optional(), + connectionId: z.string().optional() +}); + +export const AzureKeyVaultPkiSyncListItemSchema = z.object({ + name: z.literal("Azure Key Vault"), + connection: z.literal(AppConnection.AzureKeyVault), + destination: z.literal(PkiSync.AzureKeyVault), + canImportCertificates: z.literal(false), + canRemoveCertificates: z.literal(false) +}); 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..2e69534be --- /dev/null +++ b/backend/src/services/pki-sync/azure-key-vault/azure-key-vault-pki-sync-types.ts @@ -0,0 +1,38 @@ +import { z } from "zod"; + +import { TAzureKeyVaultConnection } from "@app/services/app-connection/azure-key-vault"; + +import { + AzureKeyVaultPkiSyncConfigSchema, + AzureKeyVaultPkiSyncSchema, + CreateAzureKeyVaultPkiSyncSchema, + UpdateAzureKeyVaultPkiSyncSchema +} from "./azure-key-vault-pki-sync-schemas"; + +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 type TAzureKeyVaultPkiSyncConfig = z.infer; + +export type TAzureKeyVaultPkiSync = z.infer; + +export type TAzureKeyVaultPkiSyncInput = z.infer; + +export type TAzureKeyVaultPkiSyncUpdate = z.infer; + +export type TAzureKeyVaultPkiSyncWithCredentials = TAzureKeyVaultPkiSync & { + connection: TAzureKeyVaultConnection; +}; diff --git a/backend/src/services/pki-sync/azure-key-vault/index.ts b/backend/src/services/pki-sync/azure-key-vault/index.ts new file mode 100644 index 000000000..cc9c64f9a --- /dev/null +++ b/backend/src/services/pki-sync/azure-key-vault/index.ts @@ -0,0 +1,4 @@ +export * from "./azure-key-vault-pki-sync-constants"; +export * from "./azure-key-vault-pki-sync-fns"; +export * from "./azure-key-vault-pki-sync-schemas"; +export * from "./azure-key-vault-pki-sync-types"; diff --git a/backend/src/services/pki-sync/pki-sync-cleanup-queue.ts b/backend/src/services/pki-sync/pki-sync-cleanup-queue.ts new file mode 100644 index 000000000..f6fd32e33 --- /dev/null +++ b/backend/src/services/pki-sync/pki-sync-cleanup-queue.ts @@ -0,0 +1,94 @@ +import { getConfig } from "@app/lib/config/env"; +import { logger } from "@app/lib/logger"; +import { QueueJobs, QueueName, TQueueServiceFactory } from "@app/queue"; + +import { TPkiSyncDALFactory } from "./pki-sync-dal"; +import { TPkiSyncQueueFactory } from "./pki-sync-queue"; + +type TPkiSyncCleanupQueueServiceFactoryDep = { + queueService: TQueueServiceFactory; + pkiSyncDAL: Pick; + pkiSyncQueue: Pick; +}; + +export type TPkiSyncCleanupQueueServiceFactory = ReturnType; + +export const pkiSyncCleanupQueueServiceFactory = ({ + queueService, + pkiSyncDAL, + pkiSyncQueue +}: TPkiSyncCleanupQueueServiceFactoryDep) => { + const appCfg = getConfig(); + + const syncExpiredCertificatesForPkiSyncs = async () => { + try { + const pkiSyncsWithExpiredCerts = await pkiSyncDAL.findPkiSyncsWithExpiredCertificates(); + + if (pkiSyncsWithExpiredCerts.length === 0) { + logger.info("No PKI syncs found with certificates that expired the previous day"); + return; + } + + logger.info( + `Found ${pkiSyncsWithExpiredCerts.length} PKI sync(s) with certificates that expired the previous day` + ); + + // Trigger sync for each PKI sync that has expired certificates + for (const { id: syncId, subscriberId } of pkiSyncsWithExpiredCerts) { + try { + // eslint-disable-next-line no-await-in-loop + await pkiSyncQueue.queuePkiSyncSyncCertificatesById({ + syncId + }); + logger.info( + `Successfully queued PKI sync ${syncId} for subscriber ${subscriberId} due to expired certificates` + ); + } catch (error) { + logger.error(error, `Failed to queue PKI sync ${syncId} for subscriber ${subscriberId}`); + } + } + } catch (error) { + logger.error(error, "Failed to sync expired certificates for PKI syncs"); + throw error; + } + }; + + const init = async () => { + if (appCfg.isSecondaryInstance) { + return; + } + + await queueService.stopRepeatableJob( + QueueName.PkiSyncCleanup, + QueueJobs.PkiSyncCleanup, + { pattern: "0 0 * * *", utc: true }, + QueueName.PkiSyncCleanup // just a job id + ); + + await queueService.startPg( + QueueJobs.PkiSyncCleanup, + async () => { + try { + logger.info(`${QueueName.PkiSyncCleanup}: queue task started`); + await syncExpiredCertificatesForPkiSyncs(); + logger.info(`${QueueName.PkiSyncCleanup}: queue task completed`); + } catch (error) { + logger.error(error, `${QueueName.PkiSyncCleanup}: PKI sync cleanup failed`); + throw error; + } + }, + { + batchSize: 1, + workerCount: 1, + pollingIntervalSeconds: 120 + } + ); + + await queueService.schedulePg(QueueJobs.PkiSyncCleanup, "0 0 * * *", undefined, { tz: "UTC" }); + }; + + return { + init, + syncExpiredCertificatesForPkiSyncs + }; +}; 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..460bbfc42 --- /dev/null +++ b/backend/src/services/pki-sync/pki-sync-dal.ts @@ -0,0 +1,314 @@ +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("projectId").withSchema(TableName.AppConnection).as("appConnectionProjectId"), + 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") + ); + + if (filter) { + // eslint-disable-next-line @typescript-eslint/no-misused-promises + void query.where(buildFindFilter(prependTableNameToFindFilter(TableName.PkiSync, filter))); + } + + return query; +}; + +const basePkiSyncWithSubscriberQuery = ({ + 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`) + .leftJoin(TableName.PkiSubscriber, `${TableName.PkiSync}.subscriberId`, `${TableName.PkiSubscriber}.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("projectId").withSchema(TableName.AppConnection).as("appConnectionProjectId"), + 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"), + // pki subscriber fields + db.ref("id").withSchema(TableName.PkiSubscriber).as("pkiSubscriberId"), + db.ref("name").withSchema(TableName.PkiSubscriber).as("subscriberName") + ); + + 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, + appConnectionProjectId, + 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, + projectId: appConnectionProjectId, + method: appConnectionMethod, + description: appConnectionDescription, + version: appConnectionVersion, + gatewayId: appConnectionGatewayId, + createdAt: appConnectionCreatedAt, + updatedAt: appConnectionUpdatedAt, + isPlatformManagedCredentials: appConnectionIsPlatformManagedCredentials + } + }; +}; + +const expandPkiSyncWithSubscriber = (pkiSync: Awaited>[number]) => { + const { + appConnectionName, + appConnectionApp, + appConnectionEncryptedCredentials, + appConnectionOrgId, + appConnectionProjectId, + appConnectionMethod, + appConnectionDescription, + appConnectionVersion, + appConnectionGatewayId, + appConnectionCreatedAt, + appConnectionUpdatedAt, + appConnectionIsPlatformManagedCredentials, + pkiSubscriberId, + subscriberName, + ...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, + projectId: appConnectionProjectId, + method: appConnectionMethod, + description: appConnectionDescription, + version: appConnectionVersion, + gatewayId: appConnectionGatewayId, + createdAt: appConnectionCreatedAt, + updatedAt: appConnectionUpdatedAt, + isPlatformManagedCredentials: appConnectionIsPlatformManagedCredentials + }, + subscriber: pkiSubscriberId && subscriberName ? { id: pkiSubscriberId, name: subscriberName } : null + }; +}; + +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 findByProjectIdWithSubscribers = async (projectId: string, tx?: Knex) => { + try { + const pkiSyncs = await basePkiSyncWithSubscriberQuery({ filter: { projectId }, db, tx }); + return pkiSyncs.map(expandPkiSyncWithSubscriber); + } catch (error) { + throw new DatabaseError({ error, name: "Find By Project ID With Subscribers - 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); + }; + + const findPkiSyncsWithExpiredCertificates = async (): Promise> => { + try { + const yesterday = new Date(); + yesterday.setDate(yesterday.getDate() - 1); + yesterday.setHours(0, 0, 0, 0); + + const today = new Date(); + today.setHours(0, 0, 0, 0); + + const pkiSyncs = (await db + .replicaNode()(TableName.PkiSync) + .select(`${TableName.PkiSync}.id`, `${TableName.PkiSync}.subscriberId`) + .innerJoin( + TableName.Certificate, + `${TableName.PkiSync}.subscriberId`, + `${TableName.Certificate}.pkiSubscriberId` + ) + .where(`${TableName.Certificate}.notAfter`, ">=", yesterday) + .where(`${TableName.Certificate}.notAfter`, "<", today) + .whereNotNull(`${TableName.Certificate}.pkiSubscriberId`) + .whereNotNull(`${TableName.PkiSync}.subscriberId`) + .groupBy(`${TableName.PkiSync}.id`, `${TableName.PkiSync}.subscriberId`)) as Array<{ + id: string; + subscriberId: string; + }>; + + return pkiSyncs; + } catch (error) { + throw new DatabaseError({ error, name: "Find PKI syncs with expired certificates" }); + } + }; + + return { + ...pkiSyncOrm, + findByProjectId, + findByProjectIdWithSubscribers, + findBySubscriberId, + findByIdAndProjectId, + findByNameAndProjectId, + findById, + findOne, + find, + create, + updateById, + findPkiSyncsWithExpiredCertificates + }; +}; 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..fadd70914 --- /dev/null +++ b/backend/src/services/pki-sync/pki-sync-enums.ts @@ -0,0 +1,16 @@ +export enum PkiSync { + AzureKeyVault = "azure-key-vault" +} + +export enum PkiSyncStatus { + Pending = "pending", + Running = "running", + Succeeded = "succeeded", + Failed = "failed" +} + +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..343afa626 --- /dev/null +++ b/backend/src/services/pki-sync/pki-sync-fns.ts @@ -0,0 +1,223 @@ +import * as handlebars from "handlebars"; +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 { AZURE_KEY_VAULT_PKI_SYNC_LIST_OPTION } from "./azure-key-vault/azure-key-vault-pki-sync-constants"; +import { azureKeyVaultPkiSyncFactory } from "./azure-key-vault/azure-key-vault-pki-sync-fns"; +import { PkiSync } from "./pki-sync-enums"; +import { TCertificateMap, TPkiSyncWithCredentials } from "./pki-sync-types"; + +const ENTERPRISE_PKI_SYNCS: PkiSync[] = []; + +const PKI_SYNC_LIST_OPTIONS = { + [PkiSync.AzureKeyVault]: AZURE_KEY_VAULT_PKI_SYNC_LIST_OPTION +}; + +export const enterprisePkiSyncCheck = async ( + licenseService: Pick, + orgId: string, + pkiSyncDestination: PkiSync, + errorMessage?: string +) => { + const plan = await licenseService.getPlan(orgId); + + if (!plan.enterpriseCertificateSyncs && 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(PKI_SYNC_LIST_OPTIONS).sort((a, b) => a.name.localeCompare(b.name)); +}; + +export const getPkiSyncProviderCapabilities = (destination: PkiSync) => { + const providerOption = PKI_SYNC_LIST_OPTIONS[destination]; + if (!providerOption) { + throw new BadRequestError({ message: `Unsupported PKI sync destination: ${destination}` }); + } + + return { + canImportCertificates: providerOption.canImportCertificates, + canRemoveCertificates: providerOption.canRemoveCertificates + }; +}; + +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 applyCertificateNameSchema = ( + certificateMap: TCertificateMap, + environment: string, + schema?: string +): TCertificateMap => { + if (!schema) return certificateMap; + + const processedCertificateMap: TCertificateMap = {}; + + for (const [certificateId, value] of Object.entries(certificateMap)) { + const newName = handlebars.compile(schema)({ + certificateId, + environment + }); + + processedCertificateMap[newName] = value; + } + + return processedCertificateMap; +}; + +export const stripCertificateNameSchema = ( + certificateMap: TCertificateMap, + environment: string, + schema?: string +): TCertificateMap => { + if (!schema) return certificateMap; + + const compiledSchemaPattern = handlebars.compile(schema)({ + certificateId: "{{certificateId}}", + environment + }); + + const parts = compiledSchemaPattern.split("{{certificateId}}"); + const prefix = parts[0]; + const suffix = parts[parts.length - 1]; + + const strippedMap: TCertificateMap = {}; + + for (const [name, value] of Object.entries(certificateMap)) { + if (!name.startsWith(prefix) || !name.endsWith(suffix)) { + // eslint-disable-next-line no-continue + continue; + } + + const strippedName = name.slice(prefix.length, name.length - suffix.length); + strippedMap[strippedName] = value; + } + + return strippedMap; +}; + +export const matchesCertificateNameSchema = (name: string, environment: string, schema?: string): boolean => { + if (!schema) return true; + + const compiledSchemaPattern = handlebars.compile(schema)({ + certificateId: "{{certificateId}}", + environment + }); + + if (!compiledSchemaPattern.includes("{{certificateId}}")) { + return name === compiledSchemaPattern; + } + + const parts = compiledSchemaPattern.split("{{certificateId}}"); + const prefix = parts[0]; + const suffix = parts[parts.length - 1]; + + if (prefix === "" && suffix === "") return true; + + // If prefix is empty, name must end with suffix + if (prefix === "") return name.endsWith(suffix); + + // If suffix is empty, name must start with prefix + if (suffix === "") return name.startsWith(prefix); + + // Name must start with prefix and end with suffix + return name.startsWith(prefix) && name.endsWith(suffix); +}; + +const isAzureKeyVaultPkiSync = (pkiSync: TPkiSyncWithCredentials): boolean => { + return pkiSync.destination === PkiSync.AzureKeyVault; +}; + +export const PkiSyncFns = { + getCertificates: async ( + pkiSync: TPkiSyncWithCredentials, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + dependencies: { + appConnectionDAL: Pick; + kmsService: Pick; + } + ): Promise => { + switch (pkiSync.destination) { + case PkiSync.AzureKeyVault: { + throw new Error( + "Azure Key Vault does not support importing certificates into Infisical (private keys cannot be extracted)" + ); + } + 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; + details?: { + failedUploads?: Array<{ name: string; error: string }>; + failedRemovals?: Array<{ name: string; error: string }>; + skippedCertificates?: Array<{ name: string; reason: string }>; + }; + }> => { + switch (pkiSync.destination) { + case PkiSync.AzureKeyVault: { + if (!isAzureKeyVaultPkiSync(pkiSync)) { + throw new Error("Invalid Azure Key Vault PKI sync configuration"); + } + const azureKeyVaultPkiSync = azureKeyVaultPkiSyncFactory(dependencies); + return azureKeyVaultPkiSync.syncCertificates(pkiSync, 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: { + if (!isAzureKeyVaultPkiSync(pkiSync)) { + throw new Error("Invalid Azure Key Vault PKI sync configuration"); + } + const azureKeyVaultPkiSync = azureKeyVaultPkiSyncFactory(dependencies); + await azureKeyVaultPkiSync.removeCertificates(pkiSync, 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..5967a6c97 --- /dev/null +++ b/backend/src/services/pki-sync/pki-sync-queue.ts @@ -0,0 +1,753 @@ +/* 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 handlebars from "handlebars"; + +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 { 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 { TCertificateAuthorityCertDALFactory } from "../certificate-authority/certificate-authority-cert-dal"; +import { TCertificateAuthorityDALFactory } from "../certificate-authority/certificate-authority-dal"; +import { getCaCertChain } from "../certificate-authority/certificate-authority-fns"; +import { TPkiSyncDALFactory } from "./pki-sync-dal"; +import { PkiSyncStatus } from "./pki-sync-enums"; +import { PkiSyncError } from "./pki-sync-errors"; +import { enterprisePkiSyncCheck, parsePkiSyncErrorMessage, PkiSyncFns } from "./pki-sync-fns"; +import { + TCertificateMap, + TPkiSyncImportCertificatesDTO, + TPkiSyncRaw, + TPkiSyncRemoveCertificatesDTO, + TPkiSyncSyncCertificatesDTO, + TPkiSyncWithCredentials, + TQueuePkiSyncImportCertificatesByIdDTO, + TQueuePkiSyncRemoveCertificatesByIdDTO, + TQueuePkiSyncSyncCertificatesByIdDTO +} 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; + projectDAL: TProjectDALFactory; + licenseService: Pick; + certificateDAL: Pick< + TCertificateDALFactory, + "findLatestActiveCertForSubscriber" | "findAllActiveCertsForSubscriber" | "create" + >; + certificateBodyDAL: Pick; + certificateSecretDAL: Pick; + certificateAuthorityDAL: Pick; + certificateAuthorityCertDAL: 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, + projectDAL, + licenseService, + certificateDAL, + certificateBodyDAL, + certificateSecretDAL, + certificateAuthorityDAL, + certificateAuthorityCertDAL +}: 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 $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 + }); + + for (const certificate of certificates) { + try { + // 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; + } + + let certificateChain: string | undefined; + try { + if (certBody.encryptedCertificateChain) { + const decryptedCertChain = await kmsDecryptor({ + cipherTextBlob: certBody.encryptedCertificateChain + }); + certificateChain = decryptedCertChain.toString(); + } else if (certificate.caCertId) { + const { caCert, caCertChain } = await getCaCertChain({ + caCertId: certificate.caCertId, + certificateAuthorityDAL, + certificateAuthorityCertDAL, + projectDAL, + kmsService + }); + certificateChain = `${caCert}\n${caCertChain}`.trim(); + } + } catch (chainError) { + logger.warn( + { certificateId: certificate.id, subscriberId, error: chainError }, + "Certificate chain not found or could not be decrypted - certificate may be imported or chain was not stored" + ); + // Continue without certificate chain + certificateChain = undefined; + } + + let certificateName: string; + const syncOptions = pkiSync.syncOptions as { certificateNameSchema?: string } | undefined; + const certificateNameSchema = syncOptions?.certificateNameSchema; + + if (certificateNameSchema) { + const environment = "global"; + certificateName = handlebars.compile(certificateNameSchema)({ + certificateId: certificate.id.replace(/-/g, ""), + environment + }); + } else { + certificateName = `Infisical-${certificate.id.replace(/-/g, "")}`; + } + + certificateMap[certificateName] = { + cert: certificatePem, + privateKey: certPrivateKey || "", + certificateChain + }; + } 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 $importCertificates = async (): Promise => { + throw new Error("Certificate import functionality is not implemented"); + }; + + 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, projectId: appConnectionProjectId } + } = pkiSync; + + const credentials = await decryptAppConnectionCredentials({ + orgId, + encryptedCredentials, + kmsService, + projectId: appConnectionProjectId + }); + + 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 { + 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) { + await pkiSyncDAL.updateById(pkiSync.id, { + syncStatus, + lastSyncJobId: job.id, + lastSyncMessage: syncMessage, + lastSyncedAt: isSynced ? ranAt : undefined + }); + } + } + }; + + 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 { + await $importCertificates(); + + 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 { + 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) { + await pkiSyncDAL.updateById(pkiSync.id, { + importStatus, + lastImportJobId: job.id, + lastImportMessage: importMessage, + lastImportedAt: isSuccess ? ranAt : undefined + }); + } + } + }; + + 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, projectId: appConnectionProjectId } + } = pkiSync; + + const credentials = await decryptAppConnectionCredentials({ + orgId, + encryptedCredentials, + kmsService, + projectId: appConnectionProjectId + }); + + 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 { + 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 { + await pkiSyncDAL.updateById(pkiSync.id, { + removeStatus, + lastRemoveJobId: job.id, + lastRemoveMessage: removeMessage, + lastRemovedAt: isSuccess ? ranAt : undefined + }); + } + } + } + }; + + const $handleAcquireLockFailure = async (job: PkiSyncActionJob) => { + const { syncId } = 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; + } + + 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 + }); + + break; + } + case QueueJobs.PkiSyncImportCertificates: { + 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 + }); + + break; + } + case QueueJobs.PkiSyncRemoveCertificates: { + 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 + }); + + break; + } + default: + throw new Error(`Unhandled PKI Sync Job ${String(job.name)}`); + } + }; + + queueService.start(QueueName.PkiSync, async (job) => { + const { syncId } = job.data; + + 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) { + 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) { + 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: + throw new Error(`Unhandled PKI Sync Job ${String(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..95023002e --- /dev/null +++ b/backend/src/services/pki-sync/pki-sync-schemas.ts @@ -0,0 +1,61 @@ +import RE2 from "re2"; +import { z } from "zod"; + +import { PkiSync } from "./pki-sync-enums"; + +// Schema for PKI sync options configuration +export const PkiSyncOptionsSchema = z.object({ + canImportCertificates: z.boolean(), + canRemoveCertificates: z.boolean().optional(), + certificateNameSchema: z + .string() + .optional() + .refine( + (val) => { + if (!val) return true; + + const allowedPlaceholdersRegexPart = ["{{certificateId}}"] + .map((p) => p.replace(new RE2(/[-/\\^$*+?.()|[\]{}]/g), "\\$&")) // Escape regex special characters + .join("|"); + + const allowedContentRegex = new RE2(`^([a-zA-Z0-9_\\-/]|${allowedPlaceholdersRegexPart})*$`); + const contentIsValid = allowedContentRegex.test(val); + + if (val.trim()) { + const certificateIdRegex = new RE2(/\{\{certificateId\}\}/); + const certificateIdIsPresent = certificateIdRegex.test(val); + return contentIsValid && certificateIdIsPresent; + } + + return contentIsValid; + }, + { + message: + "Certificate name schema must include exactly one {{certificateId}} placeholder. Only alphanumeric characters (a-z, A-Z, 0-9), dashes (-), underscores (_), and slashes (/) are allowed besides the placeholders." + } + ) +}); + +// Schema for destination-specific configurations +export const PkiSyncDestinationConfigSchema = z.object({ + destination: z.nativeEnum(PkiSync), + config: z.record(z.unknown()) +}); + +// Base PKI sync schema for API responses +export const PkiSyncSchema = z.object({ + id: z.string().uuid(), + name: z.string().max(255), + 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().uuid(), + subscriberId: z.string().uuid().nullable().optional(), + connectionId: z.string().uuid(), + createdAt: z.date(), + updatedAt: z.date(), + syncStatus: z.string().nullable().optional(), + lastSyncedAt: z.date().nullable().optional() +}); 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..f92c9e19f --- /dev/null +++ b/backend/src/services/pki-sync/pki-sync-service.ts @@ -0,0 +1,447 @@ +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, PkiSyncStatus } from "./pki-sync-enums"; +import { enterprisePkiSyncCheck, getPkiSyncProviderCapabilities, listPkiSyncOptions } from "./pki-sync-fns"; +import { PKI_SYNC_CONNECTION_MAP, PKI_SYNC_NAME_MAP } from "./pki-sync-maps"; +import { TPkiSyncQueueFactory } from "./pki-sync-queue"; +import { + TCreatePkiSyncDTO, + TDeletePkiSyncDTO, + TFindPkiSyncByIdDTO, + TListPkiSyncsByProjectId, + TPkiSync, + TTriggerPkiSyncImportCertificatesByIdDTO, + TTriggerPkiSyncRemoveCertificatesByIdDTO, + TTriggerPkiSyncSyncCertificatesByIdDTO, + TUpdatePkiSyncDTO +} from "./pki-sync-types"; + +const getDestinationAppType = (destination: PkiSync): AppConnection => { + const appConnection = PKI_SYNC_CONNECTION_MAP[destination]; + if (!appConnection) { + throw new BadRequestError({ + message: `Unsupported PKI sync destination: ${destination}` + }); + } + return appConnection; +}; + +type TPkiSyncServiceFactoryDep = { + pkiSyncDAL: Pick< + TPkiSyncDALFactory, + "findById" | "findByProjectIdWithSubscribers" | "findByNameAndProjectId" | "create" | "updateById" | "deleteById" + >; + 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 + }); + + let subscriber; + if (subscriberId) { + subscriber = await pkiSubscriberDAL.findById(subscriberId); + if (!subscriber || subscriber.projectId !== projectId) { + throw new NotFoundError({ message: "PKI subscriber not found" }); + } + } + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionPkiSyncActions.Create, + subscriber + ? subject(ProjectPermissionSub.PkiSyncs, { subscriberName: subscriber.name }) + : ProjectPermissionSub.PkiSyncs + ); + + // Get the destination app type based on PKI sync destination + const destinationApp = getDestinationAppType(destination); + + // Validates permission to connect and app is valid for sync destination + await appConnectionService.connectAppConnectionById(destinationApp, connectionId, actor); + + const providerCapabilities = getPkiSyncProviderCapabilities(destination); + const resolvedSyncOptions = { + ...providerCapabilities, + ...syncOptions + }; + + try { + const pkiSync = await pkiSyncDAL.create({ + name, + description, + destination, + isAutoSyncEnabled, + destinationConfig, + syncOptions: resolvedSyncOptions, + 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, + name, + description, + isAutoSyncEnabled, + destinationConfig, + syncOptions, + subscriberId, + connectionId + }: Omit, + actor: OrgServiceActor + ): Promise => { + const pkiSync = await pkiSyncDAL.findById(id); + if (!pkiSync) throw new NotFoundError({ message: "PKI sync not found" }); + + const { permission } = await permissionService.getProjectPermission({ + actor: actor.type, + actorId: actor.id, + actorAuthMethod: actor.authMethod, + actorOrgId: actor.orgId, + actionProjectType: ActionProjectType.CertificateManager, + projectId: pkiSync.projectId + }); + + let currentSubscriber; + if (pkiSync.subscriberId) { + currentSubscriber = await pkiSubscriberDAL.findById(pkiSync.subscriberId); + } + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionPkiSyncActions.Edit, + currentSubscriber + ? subject(ProjectPermissionSub.PkiSyncs, { subscriberName: currentSubscriber.name }) + : ProjectPermissionSub.PkiSyncs + ); + + if (name && name !== pkiSync.name) { + const existingPkiSync = await pkiSyncDAL.findByNameAndProjectId(name, pkiSync.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 !== pkiSync.projectId) { + throw new NotFoundError({ message: "PKI subscriber not found" }); + } + } + + if (connectionId && connectionId !== pkiSync.connectionId) { + const destinationApp = getDestinationAppType(pkiSync.destination); + await appConnectionService.connectAppConnectionById(destinationApp, connectionId, actor); + } + + let resolvedSyncOptions = syncOptions; + if (syncOptions) { + const providerCapabilities = getPkiSyncProviderCapabilities(pkiSync.destination); + + if (syncOptions.canImportCertificates && !providerCapabilities.canImportCertificates) { + throw new BadRequestError({ + message: `Certificate import is not supported for ${PKI_SYNC_NAME_MAP[pkiSync.destination]} PKI sync destination` + }); + } + + if (syncOptions.canRemoveCertificates && !providerCapabilities.canRemoveCertificates) { + throw new BadRequestError({ + message: `Certificate removal cannot be enabled for ${PKI_SYNC_NAME_MAP[pkiSync.destination]} PKI sync destination` + }); + } + + resolvedSyncOptions = { + ...providerCapabilities, + ...syncOptions + }; + } + + const updatedPkiSync = await pkiSyncDAL.updateById(id, { + name, + description, + isAutoSyncEnabled, + destinationConfig, + syncOptions: resolvedSyncOptions, + subscriberId, + connectionId + }); + + return updatedPkiSync as TPkiSync; + }; + + const deletePkiSync = async ( + { id }: Omit, + actor: OrgServiceActor + ) => { + const pkiSync = await pkiSyncDAL.findById(id); + if (!pkiSync) throw new NotFoundError({ message: "PKI sync not found" }); + + const { permission } = await permissionService.getProjectPermission({ + actor: actor.type, + actorId: actor.id, + actorAuthMethod: actor.authMethod, + actorOrgId: actor.orgId, + actionProjectType: ActionProjectType.CertificateManager, + projectId: pkiSync.projectId + }); + + let pkiSyncSubscriber; + if (pkiSync.subscriberId) { + pkiSyncSubscriber = await pkiSubscriberDAL.findById(pkiSync.subscriberId); + } + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionPkiSyncActions.Delete, + pkiSyncSubscriber + ? subject(ProjectPermissionSub.PkiSyncs, { subscriberName: pkiSyncSubscriber.name }) + : ProjectPermissionSub.PkiSyncs + ); + + return pkiSyncDAL.deleteById(id); + }; + + const listPkiSyncsByProjectId = async ( + { projectId }: TListPkiSyncsByProjectId, + actor: OrgServiceActor + ): Promise => { + 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, ProjectPermissionSub.PkiSyncs); + + const pkiSyncsWithSubscribers = await pkiSyncDAL.findByProjectIdWithSubscribers(projectId); + + return pkiSyncsWithSubscribers as TPkiSync[]; + }; + + const findPkiSyncById = async ({ id, projectId }: TFindPkiSyncByIdDTO, actor: OrgServiceActor) => { + const pkiSync = await pkiSyncDAL.findById(id); + if (!pkiSync || (projectId && pkiSync.projectId !== projectId)) { + throw new NotFoundError({ + message: `Could not find PKI Sync with ID "${id}"` + }); + } + + const { permission } = await permissionService.getProjectPermission({ + actor: actor.type, + actorId: actor.id, + actorAuthMethod: actor.authMethod, + actorOrgId: actor.orgId, + actionProjectType: ActionProjectType.CertificateManager, + projectId: pkiSync.projectId + }); + + let findSubscriber; + if (pkiSync.subscriberId) { + findSubscriber = await pkiSubscriberDAL.findById(pkiSync.subscriberId); + } + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionPkiSyncActions.Read, + findSubscriber + ? subject(ProjectPermissionSub.PkiSyncs, { subscriberName: findSubscriber.name }) + : ProjectPermissionSub.PkiSyncs + ); + + const result = { + ...pkiSync, + subscriber: findSubscriber ? { id: findSubscriber.id, name: findSubscriber.name } : null + } as TPkiSync; + + return result; + }; + + const triggerPkiSyncSyncCertificatesById = async ( + { id }: Omit, + actor: OrgServiceActor + ) => { + const pkiSync = await pkiSyncDAL.findById(id); + if (!pkiSync) throw new NotFoundError({ message: "PKI sync not found" }); + + const { permission } = await permissionService.getProjectPermission({ + actor: actor.type, + actorId: actor.id, + actorAuthMethod: actor.authMethod, + actorOrgId: actor.orgId, + actionProjectType: ActionProjectType.CertificateManager, + projectId: pkiSync.projectId + }); + + let syncSubscriber; + if (pkiSync.subscriberId) { + syncSubscriber = await pkiSubscriberDAL.findById(pkiSync.subscriberId); + } + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionPkiSyncActions.SyncCertificates, + syncSubscriber + ? subject(ProjectPermissionSub.PkiSyncs, { subscriberName: syncSubscriber.name }) + : ProjectPermissionSub.PkiSyncs + ); + + await pkiSyncQueue.queuePkiSyncSyncCertificatesById({ syncId: id }); + + return { message: "PKI sync job added to queue successfully" }; + }; + + const triggerPkiSyncImportCertificatesById = async ( + { id }: Omit, + actor: OrgServiceActor + ) => { + const pkiSync = await pkiSyncDAL.findById(id); + if (!pkiSync) throw new NotFoundError({ message: "PKI sync not found" }); + + const { permission } = await permissionService.getProjectPermission({ + actor: actor.type, + actorId: actor.id, + actorAuthMethod: actor.authMethod, + actorOrgId: actor.orgId, + actionProjectType: ActionProjectType.CertificateManager, + projectId: pkiSync.projectId + }); + + // Check if the PKI sync destination supports importing certificates + const syncOptions = listPkiSyncOptions().find((option) => option.destination === pkiSync.destination); + if (!syncOptions?.canImportCertificates) { + throw new BadRequestError({ + message: `Certificate import is not supported for ${pkiSync.destination} PKI sync destination` + }); + } + + let importSubscriber; + if (pkiSync.subscriberId) { + importSubscriber = await pkiSubscriberDAL.findById(pkiSync.subscriberId); + } + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionPkiSyncActions.ImportCertificates, + importSubscriber + ? subject(ProjectPermissionSub.PkiSyncs, { subscriberName: importSubscriber.name }) + : ProjectPermissionSub.PkiSyncs + ); + + await pkiSyncQueue.queuePkiSyncImportCertificatesById({ syncId: id }); + + return { message: "PKI sync import job added to queue successfully" }; + }; + + const triggerPkiSyncRemoveCertificatesById = async ( + { id }: Omit, + actor: OrgServiceActor + ) => { + const pkiSync = await pkiSyncDAL.findById(id); + if (!pkiSync) throw new NotFoundError({ message: "PKI sync not found" }); + + const { permission } = await permissionService.getProjectPermission({ + actor: actor.type, + actorId: actor.id, + actorAuthMethod: actor.authMethod, + actorOrgId: actor.orgId, + actionProjectType: ActionProjectType.CertificateManager, + projectId: pkiSync.projectId + }); + + let removeSubscriber; + if (pkiSync.subscriberId) { + removeSubscriber = await pkiSubscriberDAL.findById(pkiSync.subscriberId); + } + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionPkiSyncActions.RemoveCertificates, + removeSubscriber + ? subject(ProjectPermissionSub.PkiSyncs, { subscriberName: removeSubscriber.name }) + : ProjectPermissionSub.PkiSyncs + ); + + await pkiSyncQueue.queuePkiSyncRemoveCertificatesById({ syncId: id }); + + return { message: "PKI sync remove job added to queue successfully" }; + }; + + const getPkiSyncOptions = () => { + return listPkiSyncOptions(); + }; + + return { + createPkiSync, + updatePkiSync, + deletePkiSync, + listPkiSyncsByProjectId, + findPkiSyncById, + 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..bf750beee --- /dev/null +++ b/backend/src/services/pki-sync/pki-sync-types.ts @@ -0,0 +1,169 @@ +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; + 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; + appConnectionName: string; + appConnectionApp: string; + connection: { + id: string; + name: string; + app: string; + encryptedCredentials: unknown; + orgId: string; + projectId?: string; + method: string; + description?: string; + version: number; + gatewayId?: string; + createdAt: Date; + updatedAt: Date; + isPlatformManagedCredentials?: boolean; + }; + subscriber?: { + id: string; + name: string; + } | null; +}; + +export type TPkiSyncWithCredentials = TPkiSync & { + connection: { + id: string; + name: string; + app: string; + credentials: Record; + orgId: string; + }; +}; + +export type TPkiSyncListItem = TPkiSync & { + appConnectionName: string; + appConnectionApp: 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 TFindPkiSyncByIdDTO = { + id: 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 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 TPkiSyncSyncCertificatesDTO = Job< + TQueuePkiSyncSyncCertificatesByIdDTO, + void, + QueueJobs.PkiSyncSyncCertificates +>; +export type TPkiSyncImportCertificatesDTO = Job< + TQueuePkiSyncImportCertificatesByIdDTO, + void, + QueueJobs.PkiSyncImportCertificates +>; +export type TPkiSyncRemoveCertificatesDTO = Job< + TQueuePkiSyncRemoveCertificatesByIdDTO, + void, + QueueJobs.PkiSyncRemoveCertificates +>; diff --git a/backend/src/services/pki-sync/pki-sync-utils.ts b/backend/src/services/pki-sync/pki-sync-utils.ts new file mode 100644 index 000000000..a81864a17 --- /dev/null +++ b/backend/src/services/pki-sync/pki-sync-utils.ts @@ -0,0 +1,27 @@ +import { logger } from "@app/lib/logger"; + +import { TPkiSyncDALFactory } from "./pki-sync-dal"; +import { TPkiSyncQueueFactory } from "./pki-sync-queue"; + +export const triggerAutoSyncForSubscriber = async ( + subscriberId: string, + dependencies: { + pkiSyncDAL: Pick; + pkiSyncQueue: Pick; + } +) => { + try { + const pkiSyncs = await dependencies.pkiSyncDAL.find({ + subscriberId, + isAutoSyncEnabled: true + }); + + // Queue sync jobs for each auto sync enabled PKI sync + const syncPromises = pkiSyncs.map((pkiSync) => + dependencies.pkiSyncQueue.queuePkiSyncSyncCertificatesById({ syncId: pkiSync.id }) + ); + await Promise.all(syncPromises); + } catch (error) { + logger.error(error, `Failed to trigger auto sync for subscriber ${subscriberId}:`); + } +}; diff --git a/docs/api-reference/endpoints/certificate-syncs/list.mdx b/docs/api-reference/endpoints/certificate-syncs/list.mdx new file mode 100644 index 000000000..6de2c2d1b --- /dev/null +++ b/docs/api-reference/endpoints/certificate-syncs/list.mdx @@ -0,0 +1,4 @@ +--- +title: "List PKI Syncs" +openapi: "GET /api/v1/pki/syncs" +--- \ No newline at end of file diff --git a/docs/api-reference/endpoints/certificate-syncs/options.mdx b/docs/api-reference/endpoints/certificate-syncs/options.mdx new file mode 100644 index 000000000..ab2d11e48 --- /dev/null +++ b/docs/api-reference/endpoints/certificate-syncs/options.mdx @@ -0,0 +1,4 @@ +--- +title: "Options" +openapi: "GET /api/v1/pki/syncs/options" +--- \ No newline at end of file diff --git a/docs/api-reference/endpoints/pki/syncs/azure-key-vault/create.mdx b/docs/api-reference/endpoints/pki/syncs/azure-key-vault/create.mdx new file mode 100644 index 000000000..1a464cd1e --- /dev/null +++ b/docs/api-reference/endpoints/pki/syncs/azure-key-vault/create.mdx @@ -0,0 +1,4 @@ +--- +title: "Create Azure Key Vault PKI Sync" +openapi: "POST /api/v1/pki/syncs/azure-key-vault" +--- diff --git a/docs/api-reference/endpoints/pki/syncs/azure-key-vault/delete.mdx b/docs/api-reference/endpoints/pki/syncs/azure-key-vault/delete.mdx new file mode 100644 index 000000000..a08b2664d --- /dev/null +++ b/docs/api-reference/endpoints/pki/syncs/azure-key-vault/delete.mdx @@ -0,0 +1,4 @@ +--- +title: "Delete Azure Key Vault PKI Sync" +openapi: "DELETE /api/v1/pki/syncs/azure-key-vault/{pkiSyncId}" +--- diff --git a/docs/api-reference/endpoints/pki/syncs/azure-key-vault/get-by-id.mdx b/docs/api-reference/endpoints/pki/syncs/azure-key-vault/get-by-id.mdx new file mode 100644 index 000000000..0976a9dd1 --- /dev/null +++ b/docs/api-reference/endpoints/pki/syncs/azure-key-vault/get-by-id.mdx @@ -0,0 +1,4 @@ +--- +title: "Get Azure Key Vault PKI Sync by ID" +openapi: "GET /api/v1/pki/syncs/azure-key-vault/{pkiSyncId}" +--- diff --git a/docs/api-reference/endpoints/pki/syncs/azure-key-vault/list.mdx b/docs/api-reference/endpoints/pki/syncs/azure-key-vault/list.mdx new file mode 100644 index 000000000..b21f5bc33 --- /dev/null +++ b/docs/api-reference/endpoints/pki/syncs/azure-key-vault/list.mdx @@ -0,0 +1,4 @@ +--- +title: "List Azure Key Vault PKI Syncs" +openapi: "GET /api/v1/pki/syncs/azure-key-vault" +--- diff --git a/docs/api-reference/endpoints/pki/syncs/azure-key-vault/remove-certificates.mdx b/docs/api-reference/endpoints/pki/syncs/azure-key-vault/remove-certificates.mdx new file mode 100644 index 000000000..817f545c0 --- /dev/null +++ b/docs/api-reference/endpoints/pki/syncs/azure-key-vault/remove-certificates.mdx @@ -0,0 +1,4 @@ +--- +title: "Remove Certificates from Azure Key Vault" +openapi: "POST /api/v1/pki/syncs/azure-key-vault/{pkiSyncId}/remove-certificates" +--- diff --git a/docs/api-reference/endpoints/pki/syncs/azure-key-vault/sync-certificates.mdx b/docs/api-reference/endpoints/pki/syncs/azure-key-vault/sync-certificates.mdx new file mode 100644 index 000000000..ca8faced5 --- /dev/null +++ b/docs/api-reference/endpoints/pki/syncs/azure-key-vault/sync-certificates.mdx @@ -0,0 +1,4 @@ +--- +title: "Sync Certificates to Azure Key Vault" +openapi: "POST /api/v1/pki/syncs/azure-key-vault/{pkiSyncId}/sync" +--- diff --git a/docs/api-reference/endpoints/pki/syncs/azure-key-vault/update.mdx b/docs/api-reference/endpoints/pki/syncs/azure-key-vault/update.mdx new file mode 100644 index 000000000..bc0e903cf --- /dev/null +++ b/docs/api-reference/endpoints/pki/syncs/azure-key-vault/update.mdx @@ -0,0 +1,4 @@ +--- +title: "Update Azure Key Vault PKI Sync" +openapi: "PATCH /api/v1/pki/syncs/azure-key-vault/{pkiSyncId}" +--- diff --git a/docs/api-reference/endpoints/pki/syncs/get-by-id.mdx b/docs/api-reference/endpoints/pki/syncs/get-by-id.mdx new file mode 100644 index 000000000..6ec710ec8 --- /dev/null +++ b/docs/api-reference/endpoints/pki/syncs/get-by-id.mdx @@ -0,0 +1,4 @@ +--- +title: "Get PKI Sync by ID" +openapi: "GET /api/v1/pki/syncs/{pkiSyncId}" +--- diff --git a/docs/api-reference/endpoints/pki/syncs/list.mdx b/docs/api-reference/endpoints/pki/syncs/list.mdx new file mode 100644 index 000000000..4b1f1972a --- /dev/null +++ b/docs/api-reference/endpoints/pki/syncs/list.mdx @@ -0,0 +1,4 @@ +--- +title: "List PKI Syncs" +openapi: "GET /api/v1/pki/syncs" +--- diff --git a/docs/api-reference/endpoints/pki/syncs/options.mdx b/docs/api-reference/endpoints/pki/syncs/options.mdx new file mode 100644 index 000000000..a328b0832 --- /dev/null +++ b/docs/api-reference/endpoints/pki/syncs/options.mdx @@ -0,0 +1,4 @@ +--- +title: "List PKI Sync Options" +openapi: "GET /api/v1/pki/syncs/options" +--- diff --git a/docs/docs.json b/docs/docs.json index 3b7197da8..d71b16f7f 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -712,6 +712,18 @@ "documentation/platform/pki/pki-issuer", "documentation/platform/pki/integration-guides/gloo-mesh" ] + }, + { + "group": "Certificate Syncs", + "pages": [ + "documentation/platform/pki/certificate-syncs/overview", + { + "group": "Syncs", + "pages": [ + "documentation/platform/pki/certificate-syncs/azure-key-vault" + ] + } + ] } ] } @@ -2476,6 +2488,26 @@ "api-reference/endpoints/pki-alerts/update", "api-reference/endpoints/pki-alerts/delete" ] + }, + { + "group": "Certificate Syncs", + "pages": [ + "api-reference/endpoints/pki/syncs/list", + "api-reference/endpoints/pki/syncs/get-by-id", + "api-reference/endpoints/pki/syncs/options", + { + "group": "Azure Key Vault", + "pages": [ + "api-reference/endpoints/pki/syncs/azure-key-vault/list", + "api-reference/endpoints/pki/syncs/azure-key-vault/get-by-id", + "api-reference/endpoints/pki/syncs/azure-key-vault/create", + "api-reference/endpoints/pki/syncs/azure-key-vault/update", + "api-reference/endpoints/pki/syncs/azure-key-vault/delete", + "api-reference/endpoints/pki/syncs/azure-key-vault/sync-certificates", + "api-reference/endpoints/pki/syncs/azure-key-vault/remove-certificates" + ] + } + ] } ] }, diff --git a/docs/documentation/platform/pki/certificate-syncs/azure-key-vault.mdx b/docs/documentation/platform/pki/certificate-syncs/azure-key-vault.mdx new file mode 100644 index 000000000..6190ab3c3 --- /dev/null +++ b/docs/documentation/platform/pki/certificate-syncs/azure-key-vault.mdx @@ -0,0 +1,150 @@ +--- +title: "Azure Key Vault" +description: "Learn how to configure an Azure Key Vault Certificate Sync for Infisical PKI." +--- + +**Prerequisites:** + + - Set up and configure a [Certificate Authority](/documentation/platform/pki/overview) + - Create an [Azure Key Vault Connection](/integrations/app-connections/azure-key-vault) + - Ensure your network security policies allow incoming requests from Infisical to this certificate sync provider, if network restrictions apply. + + + The Azure Key Vault Certificate Sync requires the following certificate permissions to be set on the user / service principal + for Infisical to sync certificates to Azure Key Vault: `certificates/list`, `certificates/get`, `certificates/import`, `certificates/delete`. + + Any role with these permissions would work such as the **Key Vault Certificates Officer** role. + + + + Certificates synced to Azure Key Vault will be stored as certificate objects, preserving both the certificate and private key components. + + + + + 1. Navigate to **Project** > **Integrations** and select the **Certificate Syncs** tab. Click on the **Add Sync** button. + ![Certificate Syncs Tab](/images/certificate-syncs/general/certificate-sync-tab.png) + + 2. Select the **Azure Key Vault** option. + ![Select Key Vault](/images/certificate-syncs/azure-key-vault/select-key-vault-option.png) + + 3. Configure the **Source** from where certificates should be retrieved, then click **Next**. + ![Configure Source](/images/certificate-syncs/azure-key-vault/vault-source.png) + + - **PKI Subscriber**: The PKI subscriber to retrieve certificates from. + + 4. Configure the **Destination** to where certificates should be deployed, then click **Next**. + ![Configure Destination](/images/certificate-syncs/azure-key-vault/vault-destination.png) + + - **Azure Connection**: The Azure Connection to authenticate with. + - **Vault Base URL**: The URL of your Azure Key Vault. +

+ + 5. Configure the **Sync Options** to specify how certificates should be synced, then click **Next**. + ![Configure Options](/images/certificate-syncs/azure-key-vault/vault-options.png) + + - **Auto-Sync Enabled**: If enabled, certificates will automatically be synced from the source PKI subscriber when changes occur. Disable to enforce manual syncing only. + - **Enable Certificate Removal**: If enabled, Infisical will remove expired certificates from the destination during sync operations. Disable this option if you intend to manage certificate cleanup manually. + - **Certificate Name Schema** (Optional): Customize how certificate names are generated in Azure Key Vault. Use `{{certificateId}}` as a placeholder for the certificate ID. If not specified, defaults to `Infisical-{{certificateId}}`. + + + **Azure Key Vault Soft Delete**: When certificates are removed from Azure Key Vault, they are placed in a soft-deleted state rather than being permanently deleted. This means: + - Subsequent syncs will not re-add these soft-deleted certificates automatically + - To resync removed certificates, you must either manually **purge** them from Azure Key Vault or **recover** them through the Azure portal/CLI + + + 6. Configure the **Details** of your Azure Key Vault Certificate Sync, then click **Next**. + ![Configure Details](/images/certificate-syncs/azure-key-vault/vault-details.png) + + - **Name**: The name of your sync. Must be slug-friendly. + - **Description**: An optional description for your sync. + + 7. Review your Azure Key Vault Certificate Sync configuration, then click **Create Sync**. + ![Confirm Configuration](/images/certificate-syncs/azure-key-vault/vault-review.png) + + 8. If enabled, your Azure Key Vault Certificate Sync will begin syncing your certificates to the destination endpoint. + ![Sync Certificates](/images/certificate-syncs/azure-key-vault/vault-synced.png) + + + + To create an **Azure Key Vault Certificate Sync**, make an API request to the [Create Azure Key Vault Certificate Sync](/api-reference/endpoints/pki/syncs/azure-key-vault/create) API endpoint. + + ### Sample request + + ```bash Request + curl --request POST \ + --url https://app.infisical.com/api/v1/pki/syncs/azure-key-vault \ + --header 'Content-Type: application/json' \ + --data '{ + "name": "my-key-vault-cert-sync", + "projectId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "description": "an example certificate sync", + "connectionId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "subscriberId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "destination": "azure-key-vault", + "isAutoSyncEnabled": true, + "syncOptions": { + "canRemoveCertificates": true, + "certificateNameSchema": "myapp-{{certificateId}}" + }, + "destinationConfig": { + "vaultBaseUrl": "https://my-key-vault.vault.azure.net" + } + }' + ``` + + ### Sample response + + ```json Response + { + "pkiSync": { + "id": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "name": "my-key-vault-cert-sync", + "description": "an example certificate sync", + "destination": "azure-key-vault", + "isAutoSyncEnabled": true, + "destinationConfig": { + "vaultBaseUrl": "https://my-key-vault.vault.azure.net" + }, + "syncOptions": { + "canRemoveCertificates": true, + "certificateNameSchema": "myapp-{{certificateId}}" + }, + "projectId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "subscriberId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "connectionId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "createdAt": "2023-01-01T00:00:00.000Z", + "updatedAt": "2023-01-01T00:00:00.000Z" + } + } + ``` + + + +## Certificate Management + +Your Azure Key Vault Certificate Sync will: + +- **Automatic Deployment**: Deploy new certificates issued by your PKI subscriber to Azure Key Vault +- **Certificate Updates**: Update certificates in Azure Key Vault when renewals occur +- **Expiration Handling**: Optionally remove expired certificates from Azure Key Vault (if enabled) +- **Format Preservation**: Maintain certificate format and metadata during sync operations + + + Azure Key Vault Certificate Syncs support both automatic and manual synchronization modes. When auto-sync is enabled, certificates are automatically deployed as they are issued or renewed. + + +## Manual Certificate Sync + +You can manually trigger certificate synchronization from your PKI subscriber to Azure Key Vault using the sync certificates functionality. This is useful for: + +- Initial setup when you have existing certificates to deploy +- One-time sync of specific certificates +- Testing certificate sync configurations +- Force sync after making changes + +To manually sync certificates, use the [Sync Certificates](/api-reference/endpoints/pki/syncs/azure-key-vault/sync-certificates) API endpoint or the manual sync option in the Infisical UI. + + +Azure Key Vault does not support importing certificates back into Infisical due to security limitations where private keys cannot be extracted from Azure Key Vault. + \ No newline at end of file diff --git a/docs/documentation/platform/pki/certificate-syncs/overview.mdx b/docs/documentation/platform/pki/certificate-syncs/overview.mdx new file mode 100644 index 000000000..db4844a92 --- /dev/null +++ b/docs/documentation/platform/pki/certificate-syncs/overview.mdx @@ -0,0 +1,142 @@ +--- +sidebarTitle: "Overview" +description: "Learn how to sync certificates from Infisical PKI to third-party services." +--- + +Certificate Syncs enable you to sync certificates from Infisical PKI to third-party services using [App Connections](/integrations/app-connections/overview). + + + Certificate Syncs are designed to automatically deploy certificates issued by your Certificate Authority to external services, ensuring your certificates are always up-to-date across your infrastructure. + + +## Concept + +Certificate Syncs are a project-level resource used to sync certificates, via an [App Connection](/integrations/app-connections/overview), from a particular PKI subscriber (source) +to a third-party service (destination). When new certificates are issued or existing certificates are renewed, changes will automatically be propagated to the destination, ensuring +your certificates are always current. + +
+ +

+ + ```mermaid + %%{init: {'flowchart': {'curve': 'linear'} } }%% + graph LR + A[App Connection] + B[Certificate Sync] + C[Certificate 1] + D[Certificate 2] + E[Certificate 3] + F[Third-Party Service] + G[Certificate 1] + H[Certificate 2] + I[Certificate 3] + J[PKI Subscriber] + + B --> A + C --> J + D --> J + E --> J + A --> F + F --> G + F --> H + F --> I + J --> B + + classDef default fill:#ffffff,stroke:#666,stroke-width:2px,rx:10px,color:black + classDef connection fill:#FFF2B2,stroke:#E6C34A,stroke-width:2px,color:black,rx:15px + classDef certificate fill:#E6F4FF,stroke:#0096D6,stroke-width:2px,color:black,rx:15px + classDef sync fill:#F4FFE6,stroke:#96D600,stroke-width:2px,color:black,rx:15px + classDef service fill:#E6E6FF,stroke:#6B4E96,stroke-width:2px,color:black,rx:15px + classDef subscriber fill:#FFE6E6,stroke:#D63F3F,stroke-width:2px,color:black,rx:15px + + class A connection + class B sync + class C,D,E,G,H,I certificate + class F service + class J subscriber + ``` + +
+ +## Workflow + +Configuring a Certificate Sync requires three components: a source PKI subscriber to retrieve certificates from, +a destination endpoint to deploy certificates to, and configuration options to determine how your certificates +should be synced. Follow these steps to start syncing: + + + For step-by-step guides on syncing to a particular third-party service, refer to the Certificate Syncs section in the Navigation Bar. + + +1. Create App Connection: If you have not already done so, create an [App Connection](/integrations/app-connections/overview) +via the UI or API for the third-party service you intend to sync certificates to. + +2. Create Certificate Sync: Configure a Certificate Sync in the desired project by specifying the following parameters via the UI or API: + - Source: The PKI subscriber you wish to retrieve certificates from. + - Destination: The App Connection to utilize and the destination endpoint to deploy certificates to. These can vary between services. + - Options: Customize how certificates should be synced, including: + - Whether certificates should be removed from the destination when they expire + - Certificate naming schema to control how certificate names are generated in the destination + + + Only certificates managed by Infisical will be affected during sync operations. Certificates not created or + managed by Infisical will remain untouched, and changes made to Infisical-managed certificates directly + in the destination service may be overwritten by future syncs. + + + + Some third-party services do not support removing expired certificates automatically. + + +3. Utilize Sync: Any new certificates issued or renewals from the source PKI subscriber will now automatically be propagated to the destination endpoint. + + + Infisical is continuously expanding its Certificate Sync third-party service support. If the service you need isn't available, + contact us at team@infisical.com to make a request. + + +## Certificate Naming + +Certificate Syncs support flexible certificate naming through configurable naming schemas. This allows you to customize how certificate names appear in your destination services. + +### Default Naming + +By default, certificates are named using the pattern `Infisical-{certificateId}` where `{certificateId}` is the unique identifier of the certificate with hyphens removed for compatibility with services like Azure Key Vault. + +### Custom Naming Schema + +You can customize certificate naming by providing a **Certificate Name Schema** when creating or updating a Certificate Sync. The schema supports the following placeholders: + +- `{{certificateId}}` - The unique certificate identifier (required) + +**Examples:** +- `myapp-{{certificateId}}` → `myapp-abc123def456` +- `ssl/{{certificateId}}` → `ssl/abc123def456` + +**Rules:** +- Must include exactly one `{{certificateId}}` placeholder +- Only alphanumeric characters, dashes (-), underscores (_), and slashes (/) are allowed +- Certificate names matching your schema will be managed by Infisical during sync operations + +## Certificate Management + +Certificate Syncs handle the full lifecycle of certificate management: + +- **Automatic Deployment**: New certificates are automatically deployed to configured destinations +- **Renewal Propagation**: Certificate renewals are seamlessly pushed to all connected services +- **Expiration Handling**: Expired certificates can be automatically removed from destinations (service-dependent) +- **Certificate Validation**: Certificates are validated before deployment to ensure integrity + +
+ ```mermaid + graph LR + A[Certificate Issued] -->|Deploy| B[Destination Service] + C[Certificate Renewed] -->|Update| B + D[Certificate Expired] -->|Remove| B + style B fill:#F4FFE6,stroke:#96D600,stroke-width:2px,color:black,rx:15px + style A fill:#E6F4FF,stroke:#0096D6,stroke-width:2px,color:black,rx:15px + style C fill:#E6F4FF,stroke:#0096D6,stroke-width:2px,color:black,rx:15px + style D fill:#FFE6E6,stroke:#D63F3F,stroke-width:2px,color:black,rx:15px + ``` +
\ No newline at end of file diff --git a/docs/images/certificate-syncs/azure-key-vault/select-key-vault-option.png b/docs/images/certificate-syncs/azure-key-vault/select-key-vault-option.png new file mode 100644 index 000000000..0a9093ee8 Binary files /dev/null and b/docs/images/certificate-syncs/azure-key-vault/select-key-vault-option.png differ diff --git a/docs/images/certificate-syncs/azure-key-vault/vault-destination.png b/docs/images/certificate-syncs/azure-key-vault/vault-destination.png new file mode 100644 index 000000000..96295362b Binary files /dev/null and b/docs/images/certificate-syncs/azure-key-vault/vault-destination.png differ diff --git a/docs/images/certificate-syncs/azure-key-vault/vault-details.png b/docs/images/certificate-syncs/azure-key-vault/vault-details.png new file mode 100644 index 000000000..cc2ebc691 Binary files /dev/null and b/docs/images/certificate-syncs/azure-key-vault/vault-details.png differ diff --git a/docs/images/certificate-syncs/azure-key-vault/vault-options.png b/docs/images/certificate-syncs/azure-key-vault/vault-options.png new file mode 100644 index 000000000..980dad0ef Binary files /dev/null and b/docs/images/certificate-syncs/azure-key-vault/vault-options.png differ diff --git a/docs/images/certificate-syncs/azure-key-vault/vault-review.png b/docs/images/certificate-syncs/azure-key-vault/vault-review.png new file mode 100644 index 000000000..4853be086 Binary files /dev/null and b/docs/images/certificate-syncs/azure-key-vault/vault-review.png differ diff --git a/docs/images/certificate-syncs/azure-key-vault/vault-source.png b/docs/images/certificate-syncs/azure-key-vault/vault-source.png new file mode 100644 index 000000000..5ab8c5dc6 Binary files /dev/null and b/docs/images/certificate-syncs/azure-key-vault/vault-source.png differ diff --git a/docs/images/certificate-syncs/azure-key-vault/vault-synced.png b/docs/images/certificate-syncs/azure-key-vault/vault-synced.png new file mode 100644 index 000000000..2bacd1a7b Binary files /dev/null and b/docs/images/certificate-syncs/azure-key-vault/vault-synced.png differ diff --git a/docs/images/certificate-syncs/general/certificate-sync-tab.png b/docs/images/certificate-syncs/general/certificate-sync-tab.png new file mode 100644 index 000000000..f97a10aa3 Binary files /dev/null and b/docs/images/certificate-syncs/general/certificate-sync-tab.png differ 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..e465d3f34 --- /dev/null +++ b/frontend/src/components/pki-syncs/DeletePkiSyncModal.tsx @@ -0,0 +1,56 @@ +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, + destination + }); + + 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 ( + + ); +}; 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..33b06dbe4 --- /dev/null +++ b/frontend/src/components/pki-syncs/PkiSyncImportCertificatesModal.tsx @@ -0,0 +1,95 @@ +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, + destination, + 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 subscriber. 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..13c6bf031 --- /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 { PkiSyncStatus, TPkiSync } from "@app/hooks/api/pkiSyncs"; + +type Props = { + pkiSync: TPkiSync; + 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..f17855289 --- /dev/null +++ b/frontend/src/components/pki-syncs/PkiSyncRemoveCertificatesModal.tsx @@ -0,0 +1,87 @@ +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, + destination, + 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..f87a0ae4d --- /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 { PkiSyncStatus, TPkiSync } from "@app/hooks/api/pkiSyncs"; + +type Props = { + pkiSync: TPkiSync; + 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..cf1dd1764 --- /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..1fa207202 --- /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..983e4687c --- /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 { TPkiSync } from "@app/hooks/api/pkiSyncs"; + +type Props = { + pkiSyncs: TPkiSync[]; + onEdit: (pkiSync: TPkiSync) => void; + onDelete: (pkiSync: TPkiSync) => void; + onTrigger: (pkiSync: TPkiSync) => 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..02066c1cc --- /dev/null +++ b/frontend/src/components/pki-syncs/forms/CreatePkiSyncForm.tsx @@ -0,0 +1,245 @@ +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 { useProject } 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 { currentProject } = useProject(); + 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: false, + syncOptions: { + canImportCertificates: false, + canRemoveCertificates: false, + certificateNameSchema: syncOption?.defaultCertificateNameSchema + } + } as Partial, + reValidateMode: "onChange" + }); + + const onSubmit = async ({ connection, destinationConfig, ...formData }: TPkiSyncForm) => { + try { + const pkiSync = await createPkiSync.mutateAsync({ + ...formData, + connectionId: connection.id, + projectId: currentProject.id, + destinationConfig + }); + + createNotification({ + text: `Successfully added ${destinationName} Certificate Sync`, + type: "success" + }); + onComplete(pkiSync); + } catch (err: Error | unknown) { + console.error(err); + setShowConfirmation(false); + createNotification({ + title: `Failed to add ${destinationName} Certificate Sync`, + text: err instanceof Error ? err.message : "An unknown error occurred", + 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 +
+

+ Only certificates managed by Infisical will be affected during sync operations. + Certificates not created or managed by Infisical will remain untouched, and changes made + to Infisical-managed certificates directly in the destination service may 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..c4407ceee --- /dev/null +++ b/frontend/src/components/pki-syncs/forms/EditPkiSyncForm.tsx @@ -0,0 +1,108 @@ +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, + destination: pkiSync.destination + }); + + 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..95bbfc531 --- /dev/null +++ b/frontend/src/components/pki-syncs/forms/PkiSyncConnectionField.tsx @@ -0,0 +1,89 @@ +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 { ProjectPermissionSub, useProject, useProjectPermission } from "@app/context"; +import { ProjectPermissionAppConnectionActions } from "@app/context/ProjectPermissionContext/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 } = useProjectPermission(); + const { control, watch } = useFormContext(); + const { currentProject } = useProject(); + + const destination = watch("destination"); + const app = PKI_SYNC_CONNECTION_MAP[destination]; + + const { data: availableConnections, isPending } = useListAvailableAppConnections( + app, + currentProject.id + ); + + const connectionName = APP_CONNECTION_MAP[app].name; + + const canCreateConnection = permission.can( + ProjectPermissionAppConnectionActions.Create, + ProjectPermissionSub.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..4566b76e3 --- /dev/null +++ b/frontend/src/components/pki-syncs/forms/PkiSyncDestinationFields.tsx @@ -0,0 +1,23 @@ +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: + return ( +
+

Unsupported destination: {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" + /> + ( + +