From e0c6f488085c094a9f57e0bd629732245c6fedce Mon Sep 17 00:00:00 2001 From: Carlos Monastyrski Date: Thu, 20 Nov 2025 15:23:38 -0300 Subject: [PATCH] PKI syncs: fixes and add includeRootCa to PKI cert issuance endpoints --- .../server/routes/v3/certificates-router.ts | 28 +++++++++---- .../certificate-v3/certificate-v3-service.ts | 36 ++++++++++++---- .../certificate-v3/certificate-v3-types.ts | 4 ++ ...ws-certificate-manager-pki-sync-schemas.ts | 1 + .../aws-secrets-manager-pki-sync-fns.ts | 7 +--- .../aws-secrets-manager-pki-sync-schemas.ts | 1 + .../azure-key-vault-pki-sync-schemas.ts | 1 + .../pki-sync/chef/chef-pki-sync-fns.ts | 5 +-- .../pki-sync/chef/chef-pki-sync-schemas.ts | 1 + .../src/services/pki-sync/pki-sync-queue.ts | 16 +++++-- .../src/services/pki-sync/pki-sync-schemas.ts | 1 + .../PkiSyncOptionsFields.tsx | 42 +++++++++++++++++++ ...ate-manager-pki-sync-destination-schema.ts | 1 + ...ets-manager-pki-sync-destination-schema.ts | 1 + ...e-key-vault-pki-sync-destination-schema.ts | 1 + .../forms/schemas/base-pki-sync-schema.ts | 1 + .../chef-pki-sync-destination-schema.ts | 1 + frontend/src/hooks/api/pkiSyncs/enums.ts | 2 +- .../components/CertificateIssuanceModal.tsx | 1 + .../components/PkiSyncCertificatesSection.tsx | 4 +- 20 files changed, 124 insertions(+), 31 deletions(-) diff --git a/backend/src/server/routes/v3/certificates-router.ts b/backend/src/server/routes/v3/certificates-router.ts index f590aa111..f13f77c34 100644 --- a/backend/src/server/routes/v3/certificates-router.ts +++ b/backend/src/server/routes/v3/certificates-router.ts @@ -23,6 +23,8 @@ import { mapEnumsForValidation } from "@app/services/certificate-common/certific import { EnrollmentType } from "@app/services/certificate-profile/certificate-profile-types"; import { validateTemplateRegexField } from "@app/services/certificate-template/certificate-template-validators"; +import { booleanSchema } from "../sanitizedSchemas"; + interface CertificateRequestForService { commonName?: string; keyUsages?: CertKeyUsageType[]; @@ -87,7 +89,8 @@ export const registerCertificatesRouter = async (server: FastifyZodProvider) => ) .optional(), signatureAlgorithm: z.nativeEnum(CertSignatureAlgorithm), - keyAlgorithm: z.nativeEnum(CertKeyAlgorithm) + keyAlgorithm: z.nativeEnum(CertKeyAlgorithm), + removeRootsFromChain: booleanSchema.default(false).optional() }) .refine(validateTtlAndDateFields, { message: @@ -131,7 +134,8 @@ export const registerCertificatesRouter = async (server: FastifyZodProvider) => actorAuthMethod: req.permission.authMethod, actorOrgId: req.permission.orgId, profileId: req.body.profileId, - certificateRequest: mappedCertificateRequest + certificateRequest: mappedCertificateRequest, + removeRootsFromChain: req.body.removeRootsFromChain }); await server.services.auditLog.createAuditLog({ @@ -171,7 +175,8 @@ export const registerCertificatesRouter = async (server: FastifyZodProvider) => .min(1, "TTL cannot be empty") .refine((val) => ms(val) > 0, "TTL must be a positive number"), notBefore: validateCaDateField.optional(), - notAfter: validateCaDateField.optional() + notAfter: validateCaDateField.optional(), + removeRootsFromChain: booleanSchema.default(false).optional() }) .refine(validateTtlAndDateFields, { message: @@ -206,7 +211,8 @@ export const registerCertificatesRouter = async (server: FastifyZodProvider) => }, notBefore: req.body.notBefore ? new Date(req.body.notBefore) : undefined, notAfter: req.body.notAfter ? new Date(req.body.notAfter) : undefined, - enrollmentType: EnrollmentType.API + enrollmentType: EnrollmentType.API, + removeRootsFromChain: req.body.removeRootsFromChain }); await server.services.auditLog.createAuditLog({ @@ -262,7 +268,8 @@ export const registerCertificatesRouter = async (server: FastifyZodProvider) => notAfter: validateCaDateField.optional(), commonName: validateTemplateRegexField.optional(), signatureAlgorithm: z.nativeEnum(CertSignatureAlgorithm), - keyAlgorithm: z.nativeEnum(CertKeyAlgorithm) + keyAlgorithm: z.nativeEnum(CertKeyAlgorithm), + removeRootsFromChain: booleanSchema.default(false).optional() }) .refine(validateTtlAndDateFields, { message: @@ -325,7 +332,8 @@ export const registerCertificatesRouter = async (server: FastifyZodProvider) => notAfter: req.body.notAfter ? new Date(req.body.notAfter) : undefined, signatureAlgorithm: req.body.signatureAlgorithm, keyAlgorithm: req.body.keyAlgorithm - } + }, + removeRootsFromChain: req.body.removeRootsFromChain }); await server.services.auditLog.createAuditLog({ @@ -357,6 +365,11 @@ export const registerCertificatesRouter = async (server: FastifyZodProvider) => params: z.object({ certificateId: z.string().uuid() }), + body: z + .object({ + removeRootsFromChain: booleanSchema.default(false).optional() + }) + .optional(), response: { 200: z.object({ certificate: z.string().trim(), @@ -375,7 +388,8 @@ export const registerCertificatesRouter = async (server: FastifyZodProvider) => actorId: req.permission.id, actorAuthMethod: req.permission.authMethod, actorOrgId: req.permission.orgId, - certificateId: req.params.certificateId + certificateId: req.params.certificateId, + removeRootsFromChain: req.body?.removeRootsFromChain }); await server.services.auditLog.createAuditLog({ diff --git a/backend/src/services/certificate-v3/certificate-v3-service.ts b/backend/src/services/certificate-v3/certificate-v3-service.ts index 7b6538ab2..a537ddc06 100644 --- a/backend/src/services/certificate-v3/certificate-v3-service.ts +++ b/backend/src/services/certificate-v3/certificate-v3-service.ts @@ -47,7 +47,8 @@ import { convertKeyUsageArrayFromLegacy, convertKeyUsageArrayToLegacy, mapEnumsForValidation, - normalizeDateForApi + normalizeDateForApi, + removeRootCaFromChain } from "../certificate-common/certificate-utils"; import { TCertificateSyncDALFactory } from "../certificate-sync/certificate-sync-dal"; import { TPkiSyncDALFactory } from "../pki-sync/pki-sync-dal"; @@ -366,7 +367,8 @@ export const certificateV3ServiceFactory = ({ actor, actorId, actorAuthMethod, - actorOrgId + actorOrgId, + removeRootsFromChain }: TIssueCertificateFromProfileDTO): Promise => { const profile = await validateProfileAndPermissions( profileId, @@ -480,10 +482,15 @@ export const certificateV3ServiceFactory = ({ renewBeforeDays: finalRenewBeforeDays }); + let finalCertificateChain = bufferToString(certificateChain); + if (removeRootsFromChain) { + finalCertificateChain = removeRootCaFromChain(finalCertificateChain); + } + return { certificate: bufferToString(certificate), issuingCaCertificate: bufferToString(issuingCaCertificate), - certificateChain: bufferToString(certificateChain), + certificateChain: finalCertificateChain, privateKey: bufferToString(privateKey), serialNumber, certificateId: cert.id, @@ -503,7 +510,8 @@ export const certificateV3ServiceFactory = ({ actorId, actorAuthMethod, actorOrgId, - enrollmentType + enrollmentType, + removeRootsFromChain }: TSignCertificateFromProfileDTO): Promise> => { const profile = await validateProfileAndPermissions( profileId, @@ -590,7 +598,10 @@ export const certificateV3ServiceFactory = ({ }); const certificateString = extractCertificateFromBuffer(certificate as unknown as Buffer); - const certificateChainString = extractCertificateFromBuffer(certificateChain as unknown as Buffer); + let certificateChainString = extractCertificateFromBuffer(certificateChain as unknown as Buffer); + if (removeRootsFromChain) { + certificateChainString = removeRootCaFromChain(certificateChainString); + } return { certificate: certificateString, @@ -610,7 +621,8 @@ export const certificateV3ServiceFactory = ({ actor, actorId, actorAuthMethod, - actorOrgId + actorOrgId, + removeRootsFromChain }: TOrderCertificateFromProfileDTO): Promise => { const profile = await validateProfileAndPermissions( profileId, @@ -665,7 +677,8 @@ export const certificateV3ServiceFactory = ({ actor, actorId, actorAuthMethod, - actorOrgId + actorOrgId, + removeRootsFromChain }); const orderId = randomUUID(); @@ -703,7 +716,8 @@ export const certificateV3ServiceFactory = ({ actorId, actorAuthMethod, actorOrgId, - internal = false + internal = false, + removeRootsFromChain }: TRenewCertificateDTO & { internal?: boolean }): Promise => { const renewalResult = await certificateDAL.transaction(async (tx) => { const originalCert = await certificateDAL.findById(certificateId, tx); @@ -929,10 +943,14 @@ export const certificateV3ServiceFactory = ({ pkiSyncQueue }); + let finalCertificateChain = renewalResult.certificateChain; + if (removeRootsFromChain) { + finalCertificateChain = removeRootCaFromChain(finalCertificateChain); + } return { certificate: renewalResult.certificate, issuingCaCertificate: renewalResult.issuingCaCertificate, - certificateChain: renewalResult.certificateChain, + certificateChain: finalCertificateChain, serialNumber: renewalResult.serialNumber, certificateId: renewalResult.newCert.id, projectId: renewalResult.profile.projectId, diff --git a/backend/src/services/certificate-v3/certificate-v3-types.ts b/backend/src/services/certificate-v3/certificate-v3-types.ts index 8a2cf70f7..ab638c5ed 100644 --- a/backend/src/services/certificate-v3/certificate-v3-types.ts +++ b/backend/src/services/certificate-v3/certificate-v3-types.ts @@ -26,6 +26,7 @@ export type TIssueCertificateFromProfileDTO = { signatureAlgorithm?: string; keyAlgorithm?: string; }; + removeRootsFromChain?: boolean; } & Omit; export type TSignCertificateFromProfileDTO = { @@ -37,6 +38,7 @@ export type TSignCertificateFromProfileDTO = { notBefore?: Date; notAfter?: Date; enrollmentType: EnrollmentType; + removeRootsFromChain?: boolean; } & Omit; export type TOrderCertificateFromProfileDTO = { @@ -57,6 +59,7 @@ export type TOrderCertificateFromProfileDTO = { signatureAlgorithm?: string; keyAlgorithm?: string; }; + removeRootsFromChain?: boolean; } & Omit; export type TCertificateFromProfileResponse = { @@ -101,6 +104,7 @@ export type TCertificateOrderResponse = { export type TRenewCertificateDTO = { certificateId: string; + removeRootsFromChain?: boolean; } & Omit; export type TUpdateRenewalConfigDTO = { diff --git a/backend/src/services/pki-sync/aws-certificate-manager/aws-certificate-manager-pki-sync-schemas.ts b/backend/src/services/pki-sync/aws-certificate-manager/aws-certificate-manager-pki-sync-schemas.ts index 3b9f5c881..4dee71b82 100644 --- a/backend/src/services/pki-sync/aws-certificate-manager/aws-certificate-manager-pki-sync-schemas.ts +++ b/backend/src/services/pki-sync/aws-certificate-manager/aws-certificate-manager-pki-sync-schemas.ts @@ -14,6 +14,7 @@ export const AwsCertificateManagerPkiSyncConfigSchema = z.object({ const AwsCertificateManagerPkiSyncOptionsSchema = z.object({ canImportCertificates: z.boolean().default(false), canRemoveCertificates: z.boolean().default(true), + includeRootCa: z.boolean().default(false), preserveArn: z.boolean().default(true), certificateNameSchema: z .string() diff --git a/backend/src/services/pki-sync/aws-secrets-manager/aws-secrets-manager-pki-sync-fns.ts b/backend/src/services/pki-sync/aws-secrets-manager/aws-secrets-manager-pki-sync-fns.ts index 34a0b6e63..a95f3a976 100644 --- a/backend/src/services/pki-sync/aws-secrets-manager/aws-secrets-manager-pki-sync-fns.ts +++ b/backend/src/services/pki-sync/aws-secrets-manager/aws-secrets-manager-pki-sync-fns.ts @@ -17,7 +17,6 @@ import { AWSRegion } from "@app/services/app-connection/app-connection-enums"; import { getAwsConnectionConfig } from "@app/services/app-connection/aws/aws-connection-fns"; import { TAwsConnectionConfig } from "@app/services/app-connection/aws/aws-connection-types"; import { TCertificateDALFactory } from "@app/services/certificate/certificate-dal"; -import { removeRootCaFromChain } from "@app/services/certificate-common/certificate-utils"; import { TCertificateSyncDALFactory } from "@app/services/certificate-sync/certificate-sync-dal"; import { CertificateSyncStatus } from "@app/services/certificate-sync/certificate-sync-enums"; import { createConnectionQueue, RateLimitConfig } from "@app/services/connection-queue"; @@ -263,10 +262,7 @@ export const awsSecretsManagerPkiSyncFactory = ({ }; if (certificateChain && certificateChain.trim().length > 0) { - const processedCertificateChain = removeRootCaFromChain(certificateChain); - if (processedCertificateChain.trim().length > 0) { - certificateData[fieldMappings.certificateChain] = processedCertificateChain; - } + certificateData[fieldMappings.certificateChain] = certificateChain; } if (caCertificate && typeof caCertificate === "string" && caCertificate.trim().length > 0) { @@ -307,6 +303,7 @@ export const awsSecretsManagerPkiSyncFactory = ({ } else if (certificate?.renewedFromCertificateId && !preserveSecretOnRenewal) { activeExternalIdentifiers.add(existingRecord.externalIdentifier); } else if (!certificate?.renewedFromCertificateId) { + activeExternalIdentifiers.add(existingRecord.externalIdentifier); shouldProcess = false; } } diff --git a/backend/src/services/pki-sync/aws-secrets-manager/aws-secrets-manager-pki-sync-schemas.ts b/backend/src/services/pki-sync/aws-secrets-manager/aws-secrets-manager-pki-sync-schemas.ts index 063e42683..3005357a5 100644 --- a/backend/src/services/pki-sync/aws-secrets-manager/aws-secrets-manager-pki-sync-schemas.ts +++ b/backend/src/services/pki-sync/aws-secrets-manager/aws-secrets-manager-pki-sync-schemas.ts @@ -22,6 +22,7 @@ export const AwsSecretsManagerFieldMappingsSchema = z.object({ const AwsSecretsManagerPkiSyncOptionsSchema = z.object({ canImportCertificates: z.boolean().default(false), canRemoveCertificates: z.boolean().default(true), + includeRootCa: z.boolean().default(false), preserveSecretOnRenewal: z.boolean().default(true), updateExistingCertificates: z.boolean().default(true), certificateNameSchema: z diff --git a/backend/src/services/pki-sync/azure-key-vault/azure-key-vault-pki-sync-schemas.ts b/backend/src/services/pki-sync/azure-key-vault/azure-key-vault-pki-sync-schemas.ts index 90f4a119b..ab66d9b4a 100644 --- a/backend/src/services/pki-sync/azure-key-vault/azure-key-vault-pki-sync-schemas.ts +++ b/backend/src/services/pki-sync/azure-key-vault/azure-key-vault-pki-sync-schemas.ts @@ -14,6 +14,7 @@ export const AzureKeyVaultPkiSyncConfigSchema = z.object({ const AzureKeyVaultPkiSyncOptionsSchema = z.object({ canImportCertificates: z.boolean().default(false), canRemoveCertificates: z.boolean().default(true), + includeRootCa: z.boolean().default(false), enableVersioning: z.boolean().default(true), certificateNameSchema: z .string() diff --git a/backend/src/services/pki-sync/chef/chef-pki-sync-fns.ts b/backend/src/services/pki-sync/chef/chef-pki-sync-fns.ts index 797bdc85b..bf740c660 100644 --- a/backend/src/services/pki-sync/chef/chef-pki-sync-fns.ts +++ b/backend/src/services/pki-sync/chef/chef-pki-sync-fns.ts @@ -10,7 +10,6 @@ import { import { TChefDataBagItemContent } from "@app/ee/services/secret-sync/chef"; import { logger } from "@app/lib/logger"; import { TCertificateDALFactory } from "@app/services/certificate/certificate-dal"; -import { removeRootCaFromChain } from "@app/services/certificate-common/certificate-utils"; import { TCertificateSyncDALFactory } from "@app/services/certificate-sync/certificate-sync-dal"; import { CertificateSyncStatus } from "@app/services/certificate-sync/certificate-sync-enums"; import { createConnectionQueue, RateLimitConfig } from "@app/services/connection-queue"; @@ -265,13 +264,11 @@ export const chefPkiSyncFactory = ({ certificateDAL, certificateSyncDAL }: TChef } = certificateData; try { - const processedCertificateChain = certificateChain ? removeRootCaFromChain(certificateChain) : undefined; - const chefDataBagItem: ChefCertificateDataBagItem = { id: targetItemName, [fieldMappings.certificate]: cert, [fieldMappings.privateKey]: certPrivateKey, - ...(processedCertificateChain && { [fieldMappings.certificateChain]: processedCertificateChain }), + ...(certificateChain && { [fieldMappings.certificateChain]: certificateChain }), ...(caCertificate && { [fieldMappings.caCertificate]: caCertificate }) }; diff --git a/backend/src/services/pki-sync/chef/chef-pki-sync-schemas.ts b/backend/src/services/pki-sync/chef/chef-pki-sync-schemas.ts index 2d5298974..d52a20408 100644 --- a/backend/src/services/pki-sync/chef/chef-pki-sync-schemas.ts +++ b/backend/src/services/pki-sync/chef/chef-pki-sync-schemas.ts @@ -29,6 +29,7 @@ const ChefFieldMappingsSchema = z.object({ const ChefPkiSyncOptionsSchema = z.object({ canImportCertificates: z.boolean().default(false), canRemoveCertificates: z.boolean().default(true), + includeRootCa: z.boolean().default(false), preserveItemOnRenewal: z.boolean().default(true), updateExistingCertificates: z.boolean().default(true), certificateNameSchema: z diff --git a/backend/src/services/pki-sync/pki-sync-queue.ts b/backend/src/services/pki-sync/pki-sync-queue.ts index 04c5c4734..c264176f9 100644 --- a/backend/src/services/pki-sync/pki-sync-queue.ts +++ b/backend/src/services/pki-sync/pki-sync-queue.ts @@ -26,7 +26,7 @@ import { TCertificateSecretDALFactory } from "../certificate/certificate-secret- import { TCertificateAuthorityCertDALFactory } from "../certificate-authority/certificate-authority-cert-dal"; import { TCertificateAuthorityDALFactory } from "../certificate-authority/certificate-authority-dal"; import { getCaCertChain } from "../certificate-authority/certificate-authority-fns"; -import { extractRootCaFromChain } from "../certificate-common/certificate-utils"; +import { extractRootCaFromChain, removeRootCaFromChain } from "../certificate-common/certificate-utils"; import { TCertificateSyncDALFactory } from "../certificate-sync/certificate-sync-dal"; import { CertificateSyncStatus } from "../certificate-sync/certificate-sync-enums"; import { TPkiSyncDALFactory } from "./pki-sync-dal"; @@ -269,7 +269,12 @@ export const pkiSyncQueueFactory = ({ } let certificateName: string; - const syncOptions = pkiSync.syncOptions as { certificateNameSchema?: string } | undefined; + const syncOptions = pkiSync.syncOptions as + | { + certificateNameSchema?: string; + includeRootCa?: boolean; + } + | undefined; const certificateNameSchema = syncOptions?.certificateNameSchema; if (certificateNameSchema) { @@ -301,10 +306,15 @@ export const pkiSyncQueueFactory = ({ alternativeNames.push(originalLegacyName); } + let processedCertificateChain = certificateChain; + if (certificateChain && syncOptions?.includeRootCa === false) { + processedCertificateChain = removeRootCaFromChain(certificateChain); + } + certificateMap[certificateName] = { cert: certificatePem, privateKey: certPrivateKey || "", - certificateChain, + certificateChain: processedCertificateChain, caCertificate, alternativeNames, certificateId: certificate.id diff --git a/backend/src/services/pki-sync/pki-sync-schemas.ts b/backend/src/services/pki-sync/pki-sync-schemas.ts index 95023002e..635103a89 100644 --- a/backend/src/services/pki-sync/pki-sync-schemas.ts +++ b/backend/src/services/pki-sync/pki-sync-schemas.ts @@ -7,6 +7,7 @@ import { PkiSync } from "./pki-sync-enums"; export const PkiSyncOptionsSchema = z.object({ canImportCertificates: z.boolean(), canRemoveCertificates: z.boolean().optional(), + includeRootCa: z.boolean().optional().default(false), certificateNameSchema: z .string() .optional() diff --git a/frontend/src/components/pki-syncs/forms/PkiSyncOptionsFields/PkiSyncOptionsFields.tsx b/frontend/src/components/pki-syncs/forms/PkiSyncOptionsFields/PkiSyncOptionsFields.tsx index 9ba765568..b1763d2d8 100644 --- a/frontend/src/components/pki-syncs/forms/PkiSyncOptionsFields/PkiSyncOptionsFields.tsx +++ b/frontend/src/components/pki-syncs/forms/PkiSyncOptionsFields/PkiSyncOptionsFields.tsx @@ -95,6 +95,48 @@ export const PkiSyncOptionsFields = ({ destination }: Props) => { )} /> + ( + + +

+ Include Root CA in Certificate Chain{" "} + +

+ When enabled, the full certificate chain including the root CA will be + synced to the destination. +

+

+ When disabled, the root CA will be excluded from the certificate chain + during sync operations, reducing the size of the synced certificate chain. +

+

+ Most applications and services work correctly with intermediate certificates + only, as they can validate the trust chain up to a root CA they already + trust. +

+ + } + > + + +

+
+
+ )} + /> + {currentDestination === PkiSync.AwsCertificateManager && (

Certificate Details

Serial Number: {cert.serialNumber}

+

Certificate Id: {cert.id}

Common Name: {cert.commonName}

Status: {cert.status}

diff --git a/frontend/src/pages/cert-manager/PkiSyncDetailsByIDPage/components/PkiSyncCertificatesSection.tsx b/frontend/src/pages/cert-manager/PkiSyncDetailsByIDPage/components/PkiSyncCertificatesSection.tsx index aafff85cd..b453c4d79 100644 --- a/frontend/src/pages/cert-manager/PkiSyncDetailsByIDPage/components/PkiSyncCertificatesSection.tsx +++ b/frontend/src/pages/cert-manager/PkiSyncDetailsByIDPage/components/PkiSyncCertificatesSection.tsx @@ -42,14 +42,14 @@ type Props = { const getSyncStatusVariant = (status?: CertificateSyncStatus | null) => { if (status === CertificateSyncStatus.Succeeded) return "success"; if (status === CertificateSyncStatus.Failed) return "danger"; - if (status === CertificateSyncStatus.Syncing) return "neutral"; + if (status === CertificateSyncStatus.Running) return "neutral"; return "project"; }; const getSyncStatusText = (status?: CertificateSyncStatus | null) => { if (status === CertificateSyncStatus.Succeeded) return "Synced"; if (status === CertificateSyncStatus.Failed) return "Failed"; - if (status === CertificateSyncStatus.Syncing) return "Syncing"; + if (status === CertificateSyncStatus.Running) return "Syncing"; if (status === CertificateSyncStatus.Pending) return "Pending"; return "Unknown"; };