diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index 4e0b49c5b..12f26e381 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -1139,7 +1139,9 @@ export const registerRoutes = async ( certificateTemplateV2DAL, apiEnrollmentConfigDAL, estEnrollmentConfigDAL, - permissionService + permissionService, + kmsService, + projectDAL }); const pkiAlertService = pkiAlertServiceFactory({ diff --git a/backend/src/server/routes/v1/certificate-profiles-router.ts b/backend/src/server/routes/v1/certificate-profiles-router.ts index da800475c..52bc9ba08 100644 --- a/backend/src/server/routes/v1/certificate-profiles-router.ts +++ b/backend/src/server/routes/v1/certificate-profiles-router.ts @@ -35,8 +35,8 @@ export const registerCertificateProfilesRouter = async (server: FastifyZodProvid estConfig: z .object({ disableBootstrapCaValidation: z.boolean().default(false), - passphraseInput: z.string().min(1), - encryptedCaChain: z.string().optional() + passphrase: z.string().min(1), + caChain: z.string().optional() }) .optional(), apiConfig: z @@ -137,6 +137,21 @@ export const registerCertificateProfilesRouter = async (server: FastifyZodProvid expiringCertificates: z.number(), revokedCertificates: z.number() }) + .optional(), + estConfig: z + .object({ + id: z.string(), + disableBootstrapCaValidation: z.boolean(), + passphrase: z.string(), + caChain: z.string().optional() + }) + .optional(), + apiConfig: z + .object({ + id: z.string(), + autoRenew: z.boolean(), + autoRenewDays: z.number().optional() + }) .optional() }).array(), totalCount: z.number() @@ -207,8 +222,8 @@ export const registerCertificateProfilesRouter = async (server: FastifyZodProvid .object({ id: z.string(), disableBootstrapCaValidation: z.boolean(), - hashedPassphrase: z.string(), - encryptedCaChain: z.string() + passphrase: z.string(), + caChain: z.string().optional() }) .optional(), apiConfig: z @@ -333,8 +348,8 @@ export const registerCertificateProfilesRouter = async (server: FastifyZodProvid estConfig: z .object({ disableBootstrapCaValidation: z.boolean().default(false), - passphraseInput: z.string().min(1), - encryptedCaChain: z.string() + passphrase: z.string().min(1).optional(), + caChain: z.string().optional() }) .optional(), apiConfig: z diff --git a/backend/src/services/certificate-profile/certificate-profile-dal.ts b/backend/src/services/certificate-profile/certificate-profile-dal.ts index f52667b70..7aeb33571 100644 --- a/backend/src/services/certificate-profile/certificate-profile-dal.ts +++ b/backend/src/services/certificate-profile/certificate-profile-dal.ts @@ -119,12 +119,12 @@ export const certificateProfileDALFactory = (db: TDbClient) => { if (!result) return undefined; const estConfig = - result.estConfigEncryptedCaChain && result.estConfigId && result.estConfigHashedPassphrase + result.estConfigId && result.estConfigHashedPassphrase ? ({ id: result.estConfigId, disableBootstrapCaValidation: !!result.estConfigDisableBootstrapCaValidation, - hashedPassphrase: result.estConfigHashedPassphrase, - encryptedCaChain: result.estConfigEncryptedCaChain.toString("base64") + passphrase: "", + caChain: result.estConfigEncryptedCaChain ? result.estConfigEncryptedCaChain.toString("utf8") : "" } as TCertificateProfileWithConfigs["estConfig"]) : undefined; @@ -207,7 +207,7 @@ export const certificateProfileDALFactory = (db: TDbClient) => { expiringDays?: number; } = {}, tx?: Knex - ): Promise => { + ): Promise => { try { const { offset = 0, @@ -219,13 +219,13 @@ export const certificateProfileDALFactory = (db: TDbClient) => { expiringDays = 7 } = options; - let query = (tx || db)(TableName.PkiCertificateProfile).where( + let baseQuery = (tx || db)(TableName.PkiCertificateProfile).where( `${TableName.PkiCertificateProfile}.projectId`, projectId ); if (search) { - query = query.where((builder) => { + baseQuery = baseQuery.where((builder) => { void builder.where((qb) => { void qb .whereILike(`${TableName.PkiCertificateProfile}.slug`, `%${search}%`) @@ -235,26 +235,62 @@ export const certificateProfileDALFactory = (db: TDbClient) => { } if (enrollmentType) { - query = query.where(`${TableName.PkiCertificateProfile}.enrollmentType`, enrollmentType); + baseQuery = baseQuery.where(`${TableName.PkiCertificateProfile}.enrollmentType`, enrollmentType); } if (caId) { - query = query.where(`${TableName.PkiCertificateProfile}.caId`, caId); + baseQuery = baseQuery.where(`${TableName.PkiCertificateProfile}.caId`, caId); } + let query = baseQuery + .leftJoin( + TableName.PkiEstEnrollmentConfig, + `${TableName.PkiCertificateProfile}.estConfigId`, + `${TableName.PkiEstEnrollmentConfig}.id` + ) + .leftJoin( + TableName.PkiApiEnrollmentConfig, + `${TableName.PkiCertificateProfile}.apiConfigId`, + `${TableName.PkiApiEnrollmentConfig}.id` + ) + .select(selectAllTableCols(TableName.PkiCertificateProfile)) + .select( + db.ref("id").withSchema(TableName.PkiEstEnrollmentConfig).as("estId"), + db + .ref("disableBootstrapCaValidation") + .withSchema(TableName.PkiEstEnrollmentConfig) + .as("estDisableBootstrapCaValidation"), + db.ref("hashedPassphrase").withSchema(TableName.PkiEstEnrollmentConfig).as("estHashedPassphrase"), + db.ref("encryptedCaChain").withSchema(TableName.PkiEstEnrollmentConfig).as("estEncryptedCaChain"), + db.ref("id").withSchema(TableName.PkiApiEnrollmentConfig).as("apiId"), + db.ref("autoRenew").withSchema(TableName.PkiApiEnrollmentConfig).as("apiAutoRenew"), + db.ref("autoRenewDays").withSchema(TableName.PkiApiEnrollmentConfig).as("apiAutoRenewDays") + ); + if (includeMetrics) { + query = query.leftJoin( + TableName.Certificate, + `${TableName.PkiCertificateProfile}.id`, + `${TableName.Certificate}.profileId` + ); + const now = new Date(); const expiringDate = new Date(); expiringDate.setDate(now.getDate() + expiringDays); - const certificateProfiles = await query - .leftJoin( - TableName.Certificate, - `${TableName.PkiCertificateProfile}.id`, - `${TableName.Certificate}.profileId` - ) + query = query .select( selectAllTableCols(TableName.PkiCertificateProfile), + db.ref("id").withSchema(TableName.PkiEstEnrollmentConfig).as("estId"), + db + .ref("disableBootstrapCaValidation") + .withSchema(TableName.PkiEstEnrollmentConfig) + .as("estDisableBootstrapCaValidation"), + db.ref("hashedPassphrase").withSchema(TableName.PkiEstEnrollmentConfig).as("estHashedPassphrase"), + db.ref("encryptedCaChain").withSchema(TableName.PkiEstEnrollmentConfig).as("estEncryptedCaChain"), + db.ref("id").withSchema(TableName.PkiApiEnrollmentConfig).as("apiId"), + db.ref("autoRenew").withSchema(TableName.PkiApiEnrollmentConfig).as("apiAutoRenew"), + db.ref("autoRenewDays").withSchema(TableName.PkiApiEnrollmentConfig).as("apiAutoRenewDays"), db.raw("COUNT(certificates.id) as total_certificates"), db.raw( 'COUNT(CASE WHEN certificates."revokedAt" IS NULL AND certificates."notAfter" > ? THEN 1 END) as active_certificates', @@ -270,21 +306,66 @@ export const certificateProfileDALFactory = (db: TDbClient) => { ), db.raw('COUNT(CASE WHEN certificates."revokedAt" IS NOT NULL THEN 1 END) as revoked_certificates') ) - .groupBy(`${TableName.PkiCertificateProfile}.id`) - .orderBy(`${TableName.PkiCertificateProfile}.createdAt`, "desc") - .offset(offset) - .limit(limit); - - return certificateProfiles as TCertificateProfileWithRawMetrics[]; + .groupBy( + `${TableName.PkiCertificateProfile}.id`, + `${TableName.PkiEstEnrollmentConfig}.id`, + `${TableName.PkiApiEnrollmentConfig}.id` + ); } - const certificateProfiles = await query - .select(selectAllTableCols(TableName.PkiCertificateProfile)) + const results = (await query .orderBy(`${TableName.PkiCertificateProfile}.createdAt`, "desc") .offset(offset) - .limit(limit); + .limit(limit)) as Record[]; - return certificateProfiles as TCertificateProfile[]; + return results.map((result: Record) => { + const estConfig = + result.estId && result.estHashedPassphrase + ? { + id: result.estId as string, + disableBootstrapCaValidation: !!result.estDisableBootstrapCaValidation, + passphrase: "", + caChain: result.estEncryptedCaChain ? (result.estEncryptedCaChain as Buffer).toString("utf8") : "" + } + : undefined; + + const apiConfig = result.apiId + ? { + id: result.apiId as string, + autoRenew: !!result.apiAutoRenew, + autoRenewDays: (result.apiAutoRenewDays as number) || undefined + } + : undefined; + + const baseProfile = { + id: result.id, + projectId: result.projectId, + caId: result.caId, + certificateTemplateId: result.certificateTemplateId, + slug: result.slug, + description: result.description, + enrollmentType: result.enrollmentType as EnrollmentType, + estConfigId: result.estConfigId, + apiConfigId: result.apiConfigId, + createdAt: result.createdAt, + updatedAt: result.updatedAt, + estConfig, + apiConfig + }; + + if (includeMetrics) { + return { + ...baseProfile, + total_certificates: result.total_certificates, + active_certificates: result.active_certificates, + expired_certificates: result.expired_certificates, + expiring_certificates: result.expiring_certificates, + revoked_certificates: result.revoked_certificates + } as TCertificateProfileWithRawMetrics & TCertificateProfileWithConfigs; + } + + return baseProfile as TCertificateProfileWithConfigs; + }); } catch (error) { throw new DatabaseError({ error, name: "Find certificate profiles by project id" }); } diff --git a/backend/src/services/certificate-profile/certificate-profile-service.test.ts b/backend/src/services/certificate-profile/certificate-profile-service.test.ts index b03d85128..dd2d7d2d6 100644 --- a/backend/src/services/certificate-profile/certificate-profile-service.test.ts +++ b/backend/src/services/certificate-profile/certificate-profile-service.test.ts @@ -12,6 +12,8 @@ import { ActorType, AuthMethod } from "../auth/auth-type"; import type { TCertificateTemplateV2DALFactory } from "../certificate-template-v2/certificate-template-v2-dal"; import type { TApiEnrollmentConfigDALFactory } from "../enrollment-config/api-enrollment-config-dal"; import type { TEstEnrollmentConfigDALFactory } from "../enrollment-config/est-enrollment-config-dal"; +import type { TKmsServiceFactory } from "../kms/kms-service"; +import type { TProjectDALFactory } from "../project/project-dal"; import type { TCertificateProfileDALFactory } from "./certificate-profile-dal"; import { certificateProfileServiceFactory, TCertificateProfileServiceFactory } from "./certificate-profile-service"; import { EnrollmentType, TCertificateProfile, TCertificateProfileWithConfigs } from "./certificate-profile-types"; @@ -149,6 +151,22 @@ describe("CertificateProfileService", () => { }) } as unknown as Pick; + const mockKmsService = { + encryptWithKmsKey: vi + .fn() + .mockResolvedValue(() => Promise.resolve({ cipherTextBlob: Buffer.from("encrypted-data") })), + decryptWithKmsKey: vi.fn().mockResolvedValue(() => Promise.resolve(Buffer.from("decrypted-ca-chain"))), + generateKmsKey: vi.fn() + } as unknown as Pick; + + const mockProjectDAL = { + findById: vi.fn(), + findOne: vi.fn(), + updateById: vi.fn(), + findProjectBySlug: vi.fn(), + transaction: vi.fn() + } as unknown as Pick; + beforeEach(() => { vi.spyOn(ForbiddenError, "from").mockReturnValue({ throwUnlessCan: vi.fn() @@ -165,7 +183,9 @@ describe("CertificateProfileService", () => { certificateTemplateV2DAL: mockCertificateTemplateV2DAL, apiEnrollmentConfigDAL: mockApiEnrollmentConfigDAL, estEnrollmentConfigDAL: mockEstEnrollmentConfigDAL, - permissionService: mockPermissionService + permissionService: mockPermissionService, + kmsService: mockKmsService, + projectDAL: mockProjectDAL }); }); @@ -698,8 +718,9 @@ describe("CertificateProfileService", () => { certificateTemplateId: "template-123", estConfig: { disableBootstrapCaValidation: false, - passphraseInput: "secret-passphrase", - encryptedCaChain: Buffer.from("test-ca-chain-data").toString("base64") + passphrase: "secret-passphrase", + caChain: + "-----BEGIN CERTIFICATE-----\nMIIC+DCCAeCgAwIBAgIUBmCvLQ7l6CmNYjGeGXqIaS9LPuUwDQYJKoZIhvcNAQEL\nBQAwFDESMBAGA1UEChMJSW5maXNpY2FsMB4XDTI1MTAxNzE1MjczMFoXDTM1MTAx\nNzAwMDAwMFowFDESMBAGA1UEChMJSW5maXNpY2FsMIIBIjANBgkqhkiG9w0BAQEF\nAAOCAQ8AMIIBCgKCAQEAqRS0ZKh44Y1GHvD4/ryduaelVtfvqkdCmhxpCp7OTjIA\n/gPuVoBA31gxqMVcpDgIAk8dfqds0WFzFe2byhbBalNm3+FSYJkEKa1mdCnqM/mL\nt6O0V/dPv2dcepDluwWbHJIuFf5elH1F8eeyqZV5w6c980lOyDO0DVNqB6pjGlPq\njEVcvEdEtGSfIX3B2tmODilwUvl/lGjhnK6ghfots7i1Xno9VAY/YTqR0T+lyPx4\n23r+22gstJ7XCLA7aqfRyFyYaVKqubHPBwz2qKiBTc3Shc3ii/OHc5KjTpADNRDv\nvH7X5kOXYtdpGbMsJ1uY+MPwfbOVkxy4tg4HFejmyQIDAQABo0IwQDAPBgNVHRMB\nAf8EBTADAQH/MA4GA1UdDwEB/wQEAwIBBjAdBgNVHQ4EFgQUpshrlfvvw+zkoLKf\nxNUYD92/YxIwDQYJKoZIhvcNAQELBQADggEBAAWDMNe8HnoOPHF1sIUcCvJjBeUz\neB++l5Er9P+UPpkSr7+KpD+9DQGWmaOT57Vp7nBYXd42828h+cq7KEG2w5Uf6fYD\nBuitrzj2IzNznvKwOMh/qAePC17tH4mnkSnsJCMg6cvG99GG+vQoMQW7+D6VshIH\nm5hNThNGSPznk+eNk+NlIIVzD4autRn+U5geYzDaZIWfmx95gwCPK2VVw1IDExA+\naQiZi4g1JviUB97E92rZzX+Ai4GYk+CKQTxAiZPZ2M9gRFLrjKIGbRu7FaL+9lwU\nWnax4HJZ/cdVUtVp8VgaAOy7qvl5WGZ4eLopLhMkW3RyPiFr4+M3vNJocqU=\n-----END CERTIFICATE-----" } }; @@ -723,7 +744,7 @@ describe("CertificateProfileService", () => { { disableBootstrapCaValidation: estProfileData.estConfig.disableBootstrapCaValidation, hashedPassphrase: "mocked-hash", - encryptedCaChain: Buffer.from(estProfileData.estConfig.encryptedCaChain, "base64") + encryptedCaChain: Buffer.from("encrypted-data") }, undefined ); @@ -1091,8 +1112,8 @@ describe("CertificateProfileService", () => { estConfig: { id: "est-config-123", disableBootstrapCaValidation: false, - hashedPassphrase: "hashed-passphrase", - encryptedCaChain: Buffer.from("mock-ca-chain").toString("base64") + passphrase: "", + caChain: "mock-ca-chain" } } as TCertificateProfileWithConfigs; @@ -1103,9 +1124,9 @@ describe("CertificateProfileService", () => { expect(result).toEqual({ orgId: "project-123", isEnabled: true, - caChain: "bW9jay1jYS1jaGFpbg==", // base64 encoded + caChain: "mock-ca-chain", disableBootstrapCertValidation: false, - hashedPassphrase: "hashed-passphrase" + hashedPassphrase: "" }); }); @@ -1125,8 +1146,8 @@ describe("CertificateProfileService", () => { estConfig: { id: "est-config-123", disableBootstrapCaValidation: false, - hashedPassphrase: "hashed-passphrase", - encryptedCaChain: Buffer.from("mock-ca-chain").toString("base64") + passphrase: "", + caChain: "mock-ca-chain" } } as TCertificateProfileWithConfigs; diff --git a/backend/src/services/certificate-profile/certificate-profile-service.ts b/backend/src/services/certificate-profile/certificate-profile-service.ts index 232a9516c..d04e9c7cc 100644 --- a/backend/src/services/certificate-profile/certificate-profile-service.ts +++ b/backend/src/services/certificate-profile/certificate-profile-service.ts @@ -1,4 +1,5 @@ import { ForbiddenError } from "@casl/ability"; +import * as x509 from "@peculiar/x509"; import { ActionProjectType } from "@app/db/schemas"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types"; @@ -6,15 +7,20 @@ import { ProjectPermissionCertificateProfileActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; +import { extractX509CertFromChain } from "@app/lib/certificates/extract-certificate"; import { getConfig } from "@app/lib/config/env"; import { crypto } from "@app/lib/crypto/cryptography"; import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors"; import { ActorAuthMethod, ActorType } from "../auth/auth-type"; +import { isCertChainValid } from "../certificate/certificate-fns"; import { TCertificateTemplateV2DALFactory } from "../certificate-template-v2/certificate-template-v2-dal"; import { TApiEnrollmentConfigDALFactory } from "../enrollment-config/api-enrollment-config-dal"; import { TApiConfigData, TEstConfigData } from "../enrollment-config/enrollment-config-types"; import { TEstEnrollmentConfigDALFactory } from "../enrollment-config/est-enrollment-config-dal"; +import { TKmsServiceFactory } from "../kms/kms-service"; +import { TProjectDALFactory } from "../project/project-dal"; +import { getProjectKmsCertificateKeyId } from "../project/project-fns"; import { TCertificateProfileDALFactory } from "./certificate-profile-dal"; import { EnrollmentType, @@ -27,18 +33,67 @@ import { TCertificateProfileWithRawMetrics } from "./certificate-profile-types"; -const validateAndEncodeBase64CaChain = (caChain: unknown) => { +const validateAndEncryptPemCaChain = async ( + caChain: string, + projectId: string, + kmsService: Pick, + projectDAL: Pick +) => { try { - if (typeof caChain !== "string") { - throw new BadRequestError({ message: "CA chain must be a string" }); + const certificates = extractX509CertFromChain(caChain)?.map((cert) => new x509.X509Certificate(cert)); + + if (!certificates || certificates.length === 0) { + throw new BadRequestError({ message: "Failed to parse certificate chain" }); } - const buffer = Buffer.from(caChain, "base64"); - if (buffer.toString("base64") !== caChain) { - throw new BadRequestError({ message: "Invalid Base64 encoding in CA chain data" }); + + if (!(await isCertChainValid(certificates))) { + throw new BadRequestError({ message: "Invalid certificate chain" }); } - return { encryptedCaChain: buffer }; + + const certificateManagerKmsId = await getProjectKmsCertificateKeyId({ + projectId, + projectDAL, + kmsService + }); + + const kmsEncryptor = await kmsService.encryptWithKmsKey({ + kmsId: certificateManagerKmsId + }); + + const { cipherTextBlob } = await kmsEncryptor({ + plainText: Buffer.from(caChain) + }); + + return { encryptedCaChain: cipherTextBlob }; } catch (error) { - throw new BadRequestError({ message: "Failed to decode CA chain data: Invalid Base64 format" }); + throw new BadRequestError({ message: `Failed to process certificate chain: ${(error as Error).message}` }); + } +}; + +const decryptCaChain = async ( + encryptedCaChain: Buffer, + projectId: string, + kmsService: Pick, + projectDAL: Pick +): Promise => { + try { + const certificateManagerKmsId = await getProjectKmsCertificateKeyId({ + projectId, + projectDAL, + kmsService + }); + + const kmsDecryptor = await kmsService.decryptWithKmsKey({ + kmsId: certificateManagerKmsId + }); + + const decryptedCaChain = await kmsDecryptor({ + cipherTextBlob: encryptedCaChain + }); + + return decryptedCaChain.toString(); + } catch (error) { + throw new BadRequestError({ message: `Failed to decrypt certificate chain: ${(error as Error).message}` }); } }; @@ -53,6 +108,8 @@ type TCertificateProfileServiceFactoryDep = { apiEnrollmentConfigDAL: TApiEnrollmentConfigDALFactory; estEnrollmentConfigDAL: TEstEnrollmentConfigDALFactory; permissionService: Pick; + kmsService: Pick; + projectDAL: Pick; }; export type TCertificateProfileServiceFactory = ReturnType; @@ -69,7 +126,9 @@ export const certificateProfileServiceFactory = ({ certificateTemplateV2DAL, apiEnrollmentConfigDAL, estEnrollmentConfigDAL, - permissionService + permissionService, + kmsService, + projectDAL }: TCertificateProfileServiceFactoryDep) => { const createProfile = async ({ actor, @@ -140,21 +199,17 @@ export const certificateProfileServiceFactory = ({ if (data.enrollmentType === EnrollmentType.EST && data.estConfig) { const appCfg = getConfig(); // Hash the passphrase - const hashedPassphrase = await crypto.hashing().createHash(data.estConfig.passphraseInput, appCfg.SALT_ROUNDS); + const hashedPassphrase = await crypto.hashing().createHash(data.estConfig.passphrase, appCfg.SALT_ROUNDS); let encryptedCaChainBuffer: Buffer | null = null; - if (!data.estConfig.disableBootstrapCaValidation) { - try { - if (!data.estConfig.encryptedCaChain || typeof data.estConfig.encryptedCaChain !== "string") { - throw new BadRequestError({ message: "Invalid or missing CA chain data" }); - } - encryptedCaChainBuffer = Buffer.from(data.estConfig.encryptedCaChain, "base64"); - if (encryptedCaChainBuffer.toString("base64") !== data.estConfig.encryptedCaChain) { - throw new BadRequestError({ message: "Invalid Base64 encoding in CA chain data" }); - } - } catch (error) { - throw new BadRequestError({ message: "Failed to decode CA chain data: Invalid Base64 format" }); - } + if (!data.estConfig.disableBootstrapCaValidation && data.estConfig.caChain) { + const { encryptedCaChain } = await validateAndEncryptPemCaChain( + data.estConfig.caChain, + projectId, + kmsService, + projectDAL + ); + encryptedCaChainBuffer = encryptedCaChain; } const estConfig = await estEnrollmentConfigDAL.create( @@ -256,17 +311,31 @@ export const certificateProfileServiceFactory = ({ const updatedProfile = await certificateProfileDAL.transaction(async (tx) => { if (estConfig && existingProfile.estConfigId) { - await estEnrollmentConfigDAL.updateById( - existingProfile.estConfigId, - { - disableBootstrapCaValidation: estConfig.disableBootstrapCaValidation, - ...(estConfig.passphraseInput && { - hashedPassphrase: await crypto.hashing().createHash(estConfig.passphraseInput, getConfig().SALT_ROUNDS) - }), - ...(estConfig.caChain && validateAndEncodeBase64CaChain(estConfig.caChain)) - }, - tx - ); + const updateData: { + disableBootstrapCaValidation: boolean; + hashedPassphrase?: string; + encryptedCaChain?: Buffer; + } = { + disableBootstrapCaValidation: estConfig.disableBootstrapCaValidation ?? false + }; + + if (estConfig.passphrase) { + updateData.hashedPassphrase = await crypto + .hashing() + .createHash(estConfig.passphrase, getConfig().SALT_ROUNDS); + } + + if (estConfig.caChain) { + const { encryptedCaChain } = await validateAndEncryptPemCaChain( + estConfig.caChain, + existingProfile.projectId, + kmsService, + projectDAL + ); + updateData.encryptedCaChain = encryptedCaChain; + } + + await estEnrollmentConfigDAL.updateById(existingProfile.estConfigId, updateData, tx); } if (apiConfig && existingProfile.apiConfigId) { @@ -366,6 +435,25 @@ export const certificateProfileServiceFactory = ({ ProjectPermissionSub.CertificateProfiles ); + if (profile.estConfig && profile.estConfig.caChain) { + try { + const estConfig = await estEnrollmentConfigDAL.findById(profile.estConfigId!); + if (estConfig && estConfig.encryptedCaChain) { + const decryptedCaChain = await decryptCaChain( + estConfig.encryptedCaChain, + profile.projectId, + kmsService, + projectDAL + ); + profile.estConfig.caChain = decryptedCaChain; + } else { + profile.estConfig.caChain = ""; + } + } catch (error) { + profile.estConfig.caChain = ""; + } + } + return { ...profile, enrollmentType: profile.enrollmentType as EnrollmentType @@ -435,7 +523,7 @@ export const certificateProfileServiceFactory = ({ includeMetrics?: boolean; expiringDays?: number; }): Promise<{ - profiles: (TCertificateProfile & { metrics?: TCertificateProfileMetrics })[]; + profiles: (TCertificateProfileWithConfigs & { metrics?: TCertificateProfileMetrics })[]; totalCount: number; }> => { const { permission } = await permissionService.getProjectPermission({ @@ -467,24 +555,66 @@ export const certificateProfileServiceFactory = ({ caId }); - const convertedProfiles = profiles.map((profile) => { - const converted = convertDalToService(profile); - if (includeMetrics) { - const profileWithMetrics = profile as TCertificateProfileWithRawMetrics; - return { - ...converted, - metrics: { - profileId: converted.id, - totalCertificates: parseInt(String(profileWithMetrics.total_certificates || 0), 10), - activeCertificates: parseInt(String(profileWithMetrics.active_certificates || 0), 10), - expiredCertificates: parseInt(String(profileWithMetrics.expired_certificates || 0), 10), - expiringCertificates: parseInt(String(profileWithMetrics.expiring_certificates || 0), 10), - revokedCertificates: parseInt(String(profileWithMetrics.revoked_certificates || 0), 10) + const convertedProfiles = await Promise.all( + profiles.map(async (profile) => { + const profileWithConfigs = profile as TCertificateProfileWithConfigs; + + let decryptedEstConfig = profileWithConfigs.estConfig; + if (decryptedEstConfig && profileWithConfigs.estConfigId) { + try { + const estConfig = await estEnrollmentConfigDAL.findById(profileWithConfigs.estConfigId); + if (estConfig && estConfig.encryptedCaChain) { + const decryptedCaChain = await decryptCaChain( + estConfig.encryptedCaChain, + projectId, + kmsService, + projectDAL + ); + decryptedEstConfig = { + ...decryptedEstConfig, + caChain: decryptedCaChain + }; + } else if (decryptedEstConfig) { + decryptedEstConfig = { + ...decryptedEstConfig, + caChain: "" + }; + } + } catch (error) { + if (decryptedEstConfig) { + decryptedEstConfig = { + ...decryptedEstConfig, + caChain: "" + }; + } } + } + + const converted = convertDalToService(profileWithConfigs); + let result: TCertificateProfileWithConfigs & { metrics?: TCertificateProfileMetrics } = { + ...converted, + estConfig: decryptedEstConfig, + apiConfig: profileWithConfigs.apiConfig }; - } - return converted; - }); + + if (includeMetrics) { + const profileWithMetrics = profile as TCertificateProfileWithRawMetrics; + result = { + ...result, + metrics: { + profileId: converted.id, + totalCertificates: parseInt(String(profileWithMetrics.total_certificates || 0), 10), + activeCertificates: parseInt(String(profileWithMetrics.active_certificates || 0), 10), + expiredCertificates: parseInt(String(profileWithMetrics.expired_certificates || 0), 10), + expiringCertificates: parseInt(String(profileWithMetrics.expiring_certificates || 0), 10), + revokedCertificates: parseInt(String(profileWithMetrics.revoked_certificates || 0), 10) + } + }; + } + + return result; + }) + ); return { profiles: convertedProfiles, @@ -673,9 +803,9 @@ export const certificateProfileServiceFactory = ({ return { orgId: profile.projectId, isEnabled: true, - caChain: profile.estConfig.encryptedCaChain, + caChain: profile.estConfig.caChain, disableBootstrapCertValidation: profile.estConfig.disableBootstrapCaValidation, - hashedPassphrase: profile.estConfig.hashedPassphrase + hashedPassphrase: "" }; }; diff --git a/backend/src/services/certificate-profile/certificate-profile-types.ts b/backend/src/services/certificate-profile/certificate-profile-types.ts index 76bfd004a..a6d53a0f3 100644 --- a/backend/src/services/certificate-profile/certificate-profile-types.ts +++ b/backend/src/services/certificate-profile/certificate-profile-types.ts @@ -21,7 +21,7 @@ export type TCertificateProfileUpdate = Omit ["certificate-profiles", "list", params], getById: (profileId: string) => ["certificate-profiles", "get-by-id", profileId], @@ -50,6 +51,7 @@ export const useListCertificateProfiles = ({ offset = 0, search, includeMetrics = false, + includeConfigs = false, expiringDays = 7 }: TListCertificateProfilesDTO) => { return useQuery({ @@ -59,6 +61,7 @@ export const useListCertificateProfiles = ({ offset, search, includeMetrics, + includeConfigs, expiringDays }), queryFn: async () => { @@ -72,6 +75,7 @@ export const useListCertificateProfiles = ({ offset, search, includeMetrics, + includeConfigs, expiringDays } }); diff --git a/frontend/src/hooks/api/certificateProfiles/types.ts b/frontend/src/hooks/api/certificateProfiles/types.ts index 72e06d06e..54631e6d8 100644 --- a/frontend/src/hooks/api/certificateProfiles/types.ts +++ b/frontend/src/hooks/api/certificateProfiles/types.ts @@ -23,14 +23,14 @@ export type TCertificateProfileWithDetails = TCertificateProfile & { certificateTemplate?: { id: string; projectId: string; - slug: string; + name: string; description?: string; }; estConfig?: { id: string; disableBootstrapCaValidation: boolean; - hashedPassphrase: string; - encryptedCaChain: string; + passphrase: string; + caChain: string; }; apiConfig?: { id: string; @@ -48,7 +48,7 @@ export type TCreateCertificateProfileDTO = { enrollmentType: "api" | "est"; estConfig?: { disableBootstrapCaValidation?: boolean; - passphraseInput: string; + passphrase: string; caChain?: string; }; apiConfig?: { @@ -82,6 +82,7 @@ export type TListCertificateProfilesDTO = { offset?: number; search?: string; includeMetrics?: boolean; + includeConfigs?: boolean; expiringDays?: number; }; diff --git a/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/CertificateProfilesTab.tsx b/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/CertificateProfilesTab.tsx index 4334fc2c5..d034aeda1 100644 --- a/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/CertificateProfilesTab.tsx +++ b/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/CertificateProfilesTab.tsx @@ -10,7 +10,7 @@ import { ProjectPermissionSub } from "@app/context/ProjectPermissionContext/types"; import { - TCertificateProfile, + TCertificateProfileWithDetails, useDeleteCertificateProfile } from "@app/hooks/api/certificateProfiles"; @@ -23,7 +23,9 @@ export const CertificateProfilesTab = () => { const [isCreateModalOpen, setIsCreateModalOpen] = useState(false); const [isEditModalOpen, setIsEditModalOpen] = useState(false); const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); - const [selectedProfile, setSelectedProfile] = useState(null); + const [selectedProfile, setSelectedProfile] = useState( + null + ); const deleteProfile = useDeleteCertificateProfile(); @@ -36,12 +38,12 @@ export const CertificateProfilesTab = () => { setIsCreateModalOpen(true); }; - const handleEditProfile = (profile: TCertificateProfile) => { + const handleEditProfile = (profile: TCertificateProfileWithDetails) => { setSelectedProfile(profile); setIsEditModalOpen(true); }; - const handleDeleteProfile = (profile: TCertificateProfile) => { + const handleDeleteProfile = (profile: TCertificateProfileWithDetails) => { setSelectedProfile(profile); setIsDeleteModalOpen(true); }; @@ -111,7 +113,9 @@ export const CertificateProfilesTab = () => { title={`Delete Certificate Profile ${selectedProfile.slug}?`} onChange={(isOpen) => { setIsDeleteModalOpen(isOpen); - if (!isOpen) setSelectedProfile(null); + if (!isOpen) { + setSelectedProfile(null); + } }} deleteKey={selectedProfile.slug} onDeleteApproved={handleDeleteConfirm} diff --git a/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/CreateProfileModal.tsx b/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/CreateProfileModal.tsx index c4b46037e..3f7553f83 100644 --- a/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/CreateProfileModal.tsx +++ b/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/CreateProfileModal.tsx @@ -1,3 +1,4 @@ +import { useEffect } from "react"; import { Controller, useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import { z } from "zod"; @@ -47,7 +48,7 @@ const createSchema = z estConfig: z .object({ disableBootstrapCaValidation: z.boolean().optional(), - passphraseInput: z.string().min(1, "EST passphrase is required"), + passphrase: z.string().min(1, "EST passphrase is required"), caChain: z.string().min(1, "EST CA chain is required").optional() }) .refine( @@ -107,7 +108,7 @@ const editSchema = z estConfig: z .object({ disableBootstrapCaValidation: z.boolean().optional(), - passphraseInput: z.string().optional(), + passphrase: z.string().optional(), caChain: z.string().optional() }) .optional(), @@ -174,8 +175,8 @@ export const CreateProfileModal = ({ isOpen, onClose, profile, mode = "create" } ? { disableBootstrapCaValidation: profile.estConfig?.disableBootstrapCaValidation || false, - passphraseInput: "", - caChain: profile.estConfig?.encryptedCaChain || "" + passphrase: profile.estConfig?.passphrase || "", + caChain: profile.estConfig?.caChain || "" } : undefined, apiConfig: @@ -203,6 +204,34 @@ export const CreateProfileModal = ({ isOpen, onClose, profile, mode = "create" } const watchedDisableBootstrapValidation = watch("estConfig.disableBootstrapCaValidation"); const watchedAutoRenew = watch("apiConfig.autoRenew"); + useEffect(() => { + if (isEdit && profile) { + reset({ + slug: profile.slug, + description: profile.description || "", + enrollmentType: profile.enrollmentType, + certificateAuthorityId: profile.caId, + certificateTemplateId: profile.certificateTemplateId, + estConfig: + profile.enrollmentType === "est" + ? { + disableBootstrapCaValidation: + profile.estConfig?.disableBootstrapCaValidation || false, + passphrase: profile.estConfig?.passphrase || "", + caChain: profile.estConfig?.caChain || "" + } + : undefined, + apiConfig: + profile.enrollmentType === "api" + ? { + autoRenew: profile.apiConfig?.autoRenew || false, + autoRenewDays: profile.apiConfig?.autoRenewDays || 30 + } + : undefined + }); + } + }, [isEdit, profile, reset]); + const onFormSubmit = async (data: FormData) => { try { if (!currentProject?.id && !isEdit) return; @@ -237,7 +266,7 @@ export const CreateProfileModal = ({ isOpen, onClose, profile, mode = "create" } if (data.enrollmentType === "est" && data.estConfig) { createData.estConfig = { - passphraseInput: data.estConfig.passphraseInput, + passphrase: data.estConfig.passphrase, caChain: data.estConfig.caChain || undefined, disableBootstrapCaValidation: data.estConfig.disableBootstrapCaValidation }; @@ -354,7 +383,7 @@ export const CreateProfileModal = ({ isOpen, onClose, profile, mode = "create" } if (watchedEnrollmentType === "est") { setValue("estConfig", { disableBootstrapCaValidation: false, - passphraseInput: "" + passphrase: "" }); setValue("apiConfig", undefined); } else { @@ -398,7 +427,7 @@ export const CreateProfileModal = ({ isOpen, onClose, profile, mode = "create" } setValue("apiConfig", undefined); setValue("estConfig", { disableBootstrapCaValidation: false, - passphraseInput: "" + passphrase: "" }); } else { setValue("estConfig", undefined); @@ -450,7 +479,7 @@ export const CreateProfileModal = ({ isOpen, onClose, profile, mode = "create" } ( = 1 && parsed <= 365) { field.onChange(parsed); } else { - // Preserve the original field value instead of defaulting to 30 field.onChange(field.value || ""); } } diff --git a/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/ProfileList.tsx b/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/ProfileList.tsx index 24a78ba6e..2d6f601b6 100644 --- a/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/ProfileList.tsx +++ b/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/ProfileList.tsx @@ -11,15 +11,15 @@ import { } from "@app/components/v2"; import { useProject } from "@app/context"; import { - TCertificateProfile, + TCertificateProfileWithDetails, useListCertificateProfiles } from "@app/hooks/api/certificateProfiles"; import { ProfileRow } from "./ProfileRow"; interface Props { - onEditProfile: (profile: TCertificateProfile) => void; - onDeleteProfile: (profile: TCertificateProfile) => void; + onEditProfile: (profile: TCertificateProfileWithDetails) => void; + onDeleteProfile: (profile: TCertificateProfileWithDetails) => void; } export const ProfileList = ({ onEditProfile, onDeleteProfile }: Props) => { @@ -29,6 +29,7 @@ export const ProfileList = ({ onEditProfile, onDeleteProfile }: Props) => { projectId: currentProject?.id || "", limit: 100, offset: 0, + includeConfigs: true, includeMetrics: true });