From 5641d334cd9e86e24fb830679f219265fa6ab9f7 Mon Sep 17 00:00:00 2001 From: x Date: Tue, 29 Apr 2025 19:24:00 -0400 Subject: [PATCH 01/47] checkpoint --- ...03304_certificates-ca-relation-optional.ts | 16 ++ ...certificates-update-body-and-add-secret.ts | 33 +++ .../20250429223815_certificates-project-id.ts | 16 ++ backend/src/db/schemas/certificate-bodies.ts | 3 +- backend/src/db/schemas/certificate-secrets.ts | 5 +- backend/src/db/schemas/certificates.ts | 4 +- backend/src/db/schemas/organizations.ts | 1 - backend/src/db/schemas/projects.ts | 2 +- backend/src/lib/api-docs/constants.ts | 13 + .../server/routes/v1/certificate-router.ts | 73 ++++++ .../certificate/certificate-service.ts | 198 +++++++++++++++- .../services/certificate/certificate-types.ts | 11 + frontend/src/hooks/api/certificates/index.tsx | 2 +- .../src/hooks/api/certificates/mutations.tsx | 26 +- frontend/src/hooks/api/certificates/types.ts | 20 ++ .../components/CertificateImportModal.tsx | 223 ++++++++++++++++++ .../components/CertificatesSection.tsx | 33 ++- 17 files changed, 653 insertions(+), 26 deletions(-) create mode 100644 backend/src/db/migrations/20250429203304_certificates-ca-relation-optional.ts create mode 100644 backend/src/db/migrations/20250429210424_certificates-update-body-and-add-secret.ts create mode 100644 backend/src/db/migrations/20250429223815_certificates-project-id.ts create mode 100644 frontend/src/pages/cert-manager/CertificatesPage/components/CertificateImportModal.tsx diff --git a/backend/src/db/migrations/20250429203304_certificates-ca-relation-optional.ts b/backend/src/db/migrations/20250429203304_certificates-ca-relation-optional.ts new file mode 100644 index 000000000..38f106731 --- /dev/null +++ b/backend/src/db/migrations/20250429203304_certificates-ca-relation-optional.ts @@ -0,0 +1,16 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + if (await knex.schema.hasTable(TableName.Certificate)) { + await knex.schema.alterTable(TableName.Certificate, (t) => { + t.uuid("caId").nullable().alter(); + t.uuid("caCertId").nullable().alter(); + }); + } +} + +export async function down(): Promise { + // Altering back to nullable will fail +} diff --git a/backend/src/db/migrations/20250429210424_certificates-update-body-and-add-secret.ts b/backend/src/db/migrations/20250429210424_certificates-update-body-and-add-secret.ts new file mode 100644 index 000000000..cb5e44a03 --- /dev/null +++ b/backend/src/db/migrations/20250429210424_certificates-update-body-and-add-secret.ts @@ -0,0 +1,33 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + if (await knex.schema.hasTable(TableName.CertificateBody)) { + await knex.schema.alterTable(TableName.CertificateBody, (t) => { + t.binary("encryptedCertificateChain").nullable(); + }); + } + + if (!(await knex.schema.hasTable(TableName.CertificateSecret))) { + await knex.schema.createTable(TableName.CertificateSecret, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + t.timestamps(true, true, true); + t.uuid("certId").notNullable().unique(); + t.foreign("certId").references("id").inTable(TableName.Certificate).onDelete("CASCADE"); + t.binary("encryptedPrivateKey").notNullable(); + }); + } +} + +export async function down(knex: Knex): Promise { + if (await knex.schema.hasTable(TableName.CertificateSecret)) { + await knex.schema.dropTable(TableName.CertificateSecret); + } + + if (await knex.schema.hasTable(TableName.CertificateBody)) { + await knex.schema.alterTable(TableName.CertificateBody, (t) => { + t.dropColumn("encryptedCertificateChain"); + }); + } +} diff --git a/backend/src/db/migrations/20250429223815_certificates-project-id.ts b/backend/src/db/migrations/20250429223815_certificates-project-id.ts new file mode 100644 index 000000000..556913a6b --- /dev/null +++ b/backend/src/db/migrations/20250429223815_certificates-project-id.ts @@ -0,0 +1,16 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + if (await knex.schema.hasTable(TableName.Certificate)) { + await knex.schema.alterTable(TableName.Certificate, (t) => { + t.uuid("projectId").notNullable(); + t.foreign("projectId").references("id").inTable(TableName.Certificate).onDelete("CASCADE"); + }); + } +} + +export async function down(knex: Knex): Promise { + // .. tbd +} diff --git a/backend/src/db/schemas/certificate-bodies.ts b/backend/src/db/schemas/certificate-bodies.ts index 75afbddbd..10171e383 100644 --- a/backend/src/db/schemas/certificate-bodies.ts +++ b/backend/src/db/schemas/certificate-bodies.ts @@ -14,7 +14,8 @@ export const CertificateBodiesSchema = z.object({ createdAt: z.date(), updatedAt: z.date(), certId: z.string().uuid(), - encryptedCertificate: zodBuffer + encryptedCertificate: zodBuffer, + encryptedCertificateChain: zodBuffer.nullable().optional() }); export type TCertificateBodies = z.infer; diff --git a/backend/src/db/schemas/certificate-secrets.ts b/backend/src/db/schemas/certificate-secrets.ts index f8cad74f1..75e6377b2 100644 --- a/backend/src/db/schemas/certificate-secrets.ts +++ b/backend/src/db/schemas/certificate-secrets.ts @@ -5,6 +5,8 @@ import { z } from "zod"; +import { zodBuffer } from "@app/lib/zod"; + import { TImmutableDBKeys } from "./models"; export const CertificateSecretsSchema = z.object({ @@ -12,8 +14,7 @@ export const CertificateSecretsSchema = z.object({ createdAt: z.date(), updatedAt: z.date(), certId: z.string().uuid(), - pk: z.string(), - sk: z.string() + encryptedPrivateKey: zodBuffer }); export type TCertificateSecrets = z.infer; diff --git a/backend/src/db/schemas/certificates.ts b/backend/src/db/schemas/certificates.ts index 533f9b898..f46a86e0a 100644 --- a/backend/src/db/schemas/certificates.ts +++ b/backend/src/db/schemas/certificates.ts @@ -11,7 +11,7 @@ export const CertificatesSchema = z.object({ id: z.string().uuid(), createdAt: z.date(), updatedAt: z.date(), - caId: z.string().uuid(), + caId: z.string().uuid().nullable().optional(), status: z.string(), serialNumber: z.string(), friendlyName: z.string(), @@ -21,7 +21,7 @@ export const CertificatesSchema = z.object({ revokedAt: z.date().nullable().optional(), revocationReason: z.number().nullable().optional(), altNames: z.string().nullable().optional(), - caCertId: z.string().uuid(), + caCertId: z.string().uuid().nullable().optional(), certificateTemplateId: z.string().uuid().nullable().optional(), keyUsages: z.string().array().nullable().optional(), extendedKeyUsages: z.string().array().nullable().optional() diff --git a/backend/src/db/schemas/organizations.ts b/backend/src/db/schemas/organizations.ts index 902c564a7..eea1808e0 100644 --- a/backend/src/db/schemas/organizations.ts +++ b/backend/src/db/schemas/organizations.ts @@ -23,7 +23,6 @@ export const OrganizationsSchema = z.object({ defaultMembershipRole: z.string().default("member"), enforceMfa: z.boolean().default(false), selectedMfaMethod: z.string().nullable().optional(), - secretShareSendToAnyone: z.boolean().default(true).nullable().optional(), allowSecretSharingOutsideOrganization: z.boolean().default(true).nullable().optional(), shouldUseNewPrivilegeSystem: z.boolean().default(true), privilegeUpgradeInitiatedByUsername: z.string().nullable().optional(), diff --git a/backend/src/db/schemas/projects.ts b/backend/src/db/schemas/projects.ts index 2403d6cf4..297601fd0 100644 --- a/backend/src/db/schemas/projects.ts +++ b/backend/src/db/schemas/projects.ts @@ -27,7 +27,7 @@ export const ProjectsSchema = z.object({ description: z.string().nullable().optional(), type: z.string(), enforceCapitalization: z.boolean().default(false), - hasDeleteProtection: z.boolean().default(true).nullable().optional() + hasDeleteProtection: z.boolean().default(false).nullable().optional() }); export type TProjects = z.infer; diff --git a/backend/src/lib/api-docs/constants.ts b/backend/src/lib/api-docs/constants.ts index 19ee7e331..587f23892 100644 --- a/backend/src/lib/api-docs/constants.ts +++ b/backend/src/lib/api-docs/constants.ts @@ -1581,6 +1581,19 @@ export const CERTIFICATES = { certificate: "The certificate body of the certificate.", certificateChain: "The certificate chain of the certificate.", serialNumberRes: "The serial number of the certificate." + }, + IMPORT: { + projectSlug: "Slug of the project to import the certificate into.", + certificatePem: "The PEM-encoded leaf certificate.", + privateKeyPem: "The PEM-encoded private key corresponding to the certificate.", + chainPem: "The PEM-encoded chain of intermediate certificates.", + friendlyName: "A friendly name for the certificate.", + pkiCollectionId: "The ID of the PKI collection to add the certificate to.", + + certificate: "The issued certificate.", + certificateChain: "The certificate chain of the issued certificate.", + privateKey: "The private key of the issued certificate.", + serialNumber: "The serial number of the issued certificate." } }; diff --git a/backend/src/server/routes/v1/certificate-router.ts b/backend/src/server/routes/v1/certificate-router.ts index ea33e948f..1acfe0d7c 100644 --- a/backend/src/server/routes/v1/certificate-router.ts +++ b/backend/src/server/routes/v1/certificate-router.ts @@ -177,6 +177,79 @@ export const registerCertRouter = async (server: FastifyZodProvider) => { } }); + server.route({ + method: "POST", + url: "/import-certificate", + config: { + rateLimit: writeLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + hide: false, + tags: [ApiDocsTags.PkiCertificates], + description: "Import certificate", + body: z.object({ + projectSlug: z.string().trim().min(1).describe(CERTIFICATES.IMPORT.projectSlug), + + certificatePem: z.string().trim().min(1).describe(CERTIFICATES.IMPORT.certificatePem), + privateKeyPem: z.string().trim().optional().describe(CERTIFICATES.IMPORT.privateKeyPem), + chainPem: z.string().trim().optional().describe(CERTIFICATES.IMPORT.chainPem), + + friendlyName: z.string().trim().optional().describe(CERTIFICATES.IMPORT.friendlyName), + pkiCollectionId: z.string().trim().optional().describe(CERTIFICATES.IMPORT.pkiCollectionId) + }), + response: { + 200: z.object({ + certificate: z.string().trim().describe(CERTIFICATES.IMPORT.certificate), + certificateChain: z.string().trim().optional().describe(CERTIFICATES.IMPORT.certificateChain), + privateKey: z.string().trim().optional().describe(CERTIFICATES.IMPORT.privateKey), + serialNumber: z.string().trim().describe(CERTIFICATES.IMPORT.serialNumber) + }) + } + }, + handler: async (req) => { + const { certificate, certificateChain, privateKey, serialNumber } = await server.services.certificate.importCert({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + ...req.body + }); + + // TODO(andrey): Add logs + // await server.services.auditLog.createAuditLog({ + // ...req.auditLogInfo, + // projectId: ca.projectId, + // event: { + // type: EventType.ISSUE_CERT, + // metadata: { + // caId: ca.id, + // dn: ca.dn, + // serialNumber + // } + // } + // }); + + // await server.services.telemetry.sendPostHogEvents({ + // event: PostHogEventTypes.IssueCert, + // distinctId: getTelemetryDistinctId(req), + // properties: { + // caId: req.body.caId, + // certificateTemplateId: req.body.certificateTemplateId, + // commonName: req.body.commonName, + // ...req.auditLogInfo + // } + // }); + + return { + certificate, + certificateChain, + privateKey, + serialNumber + }; + } + }); + server.route({ method: "POST", url: "/sign-certificate", diff --git a/backend/src/services/certificate/certificate-service.ts b/backend/src/services/certificate/certificate-service.ts index 0ca0d64c6..3555b866b 100644 --- a/backend/src/services/certificate/certificate-service.ts +++ b/backend/src/services/certificate/certificate-service.ts @@ -1,31 +1,47 @@ import { ForbiddenError } from "@casl/ability"; import * as x509 from "@peculiar/x509"; +import { createPrivateKey, createPublicKey, sign, verify } from "crypto"; -import { ActionProjectType } from "@app/db/schemas"; +import { ActionProjectType, ProjectType } from "@app/db/schemas"; import { TCertificateAuthorityCrlDALFactory } from "@app/ee/services/certificate-authority-crl/certificate-authority-crl-dal"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; +import { BadRequestError, NotFoundError } from "@app/lib/errors"; import { TCertificateBodyDALFactory } from "@app/services/certificate/certificate-body-dal"; import { TCertificateDALFactory } from "@app/services/certificate/certificate-dal"; import { TCertificateAuthorityCertDALFactory } from "@app/services/certificate-authority/certificate-authority-cert-dal"; import { TCertificateAuthorityDALFactory } from "@app/services/certificate-authority/certificate-authority-dal"; import { TCertificateAuthoritySecretDALFactory } from "@app/services/certificate-authority/certificate-authority-secret-dal"; import { TKmsServiceFactory } from "@app/services/kms/kms-service"; +import { TPkiCollectionDALFactory } from "@app/services/pki-collection/pki-collection-dal"; +import { TPkiCollectionItemDALFactory } from "@app/services/pki-collection/pki-collection-item-dal"; import { TProjectDALFactory } from "@app/services/project/project-dal"; import { getProjectKmsCertificateKeyId } from "@app/services/project/project-fns"; import { getCaCertChain, rebuildCaCrl } from "../certificate-authority/certificate-authority-fns"; import { revocationReasonToCrlCode } from "./certificate-fns"; -import { CertStatus, TDeleteCertDTO, TGetCertBodyDTO, TGetCertDTO, TRevokeCertDTO } from "./certificate-types"; +import { + CertStatus, + TDeleteCertDTO, + TGetCertBodyDTO, + TGetCertDTO, + TImportCertDTO, + TRevokeCertDTO +} from "./certificate-types"; type TCertificateServiceFactoryDep = { - certificateDAL: Pick; - certificateBodyDAL: Pick; + certificateDAL: Pick; + certificateBodyDAL: Pick; certificateAuthorityDAL: Pick; certificateAuthorityCertDAL: Pick; certificateAuthorityCrlDAL: Pick; certificateAuthoritySecretDAL: Pick; - projectDAL: Pick; + pkiCollectionDAL: Pick; + pkiCollectionItemDAL: Pick; + projectDAL: Pick< + TProjectDALFactory, + "findProjectBySlug" | "findOne" | "updateById" | "findById" | "transaction" | "getProjectFromSplitId" + >; kmsService: Pick; permissionService: Pick; }; @@ -39,6 +55,8 @@ export const certificateServiceFactory = ({ certificateAuthorityCertDAL, certificateAuthorityCrlDAL, certificateAuthoritySecretDAL, + pkiCollectionDAL, + pkiCollectionItemDAL, projectDAL, kmsService, permissionService @@ -48,7 +66,12 @@ export const certificateServiceFactory = ({ */ const getCert = async ({ serialNumber, actorId, actorAuthMethod, actor, actorOrgId }: TGetCertDTO) => { const cert = await certificateDAL.findOne({ serialNumber }); - const ca = await certificateAuthorityDAL.findById(cert.caId); + + let ca; + + if (cert.caId) { + ca = await certificateAuthorityDAL.findById(cert.caId); + } const { permission } = await permissionService.getProjectPermission({ actor, @@ -201,10 +224,171 @@ export const certificateServiceFactory = ({ }; }; + /** + * Import certificate + */ + const importCert = async ({ + projectSlug, + pkiCollectionId, + actorId, + actorAuthMethod, + actor, + actorOrgId, + friendlyName, + certificatePem, + chainPem, + privateKeyPem + }: TImportCertDTO) => { + const collectionId = pkiCollectionId; + + const project = await projectDAL.findProjectBySlug(projectSlug, actorOrgId); + if (!project) throw new NotFoundError({ message: `Project with slug '${projectSlug}' not found` }); + let projectId = project.id; + + const certManagerProjectFromSplit = await projectDAL.getProjectFromSplitId( + projectId, + ProjectType.CertificateManager + ); + if (certManagerProjectFromSplit) { + projectId = certManagerProjectFromSplit.id; + } + + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.CertificateManager + }); + + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Create, ProjectPermissionSub.Certificates); + + // Check PKI collection + if (collectionId) { + const pkiCollection = await pkiCollectionDAL.findById(collectionId); + if (!pkiCollection) throw new NotFoundError({ message: "PKI collection not found" }); + if (pkiCollection.projectId !== projectId) throw new BadRequestError({ message: "Invalid PKI collection" }); + } + + // Parse the certificate + const certObj = new x509.X509Certificate(certificatePem); + + // Verify the certificate chain + if (chainPem) { + const chainCert = new x509.X509Certificate(chainPem); + if (!(await certObj.verify({ publicKey: chainCert.publicKey }))) { + throw new BadRequestError({ message: "Certificate chain verification failed" }); + } + } + + // If private key provided, verify it matches the certificate + if (privateKeyPem) { + try { + const message = Buffer.from("certificate-verification-test"); + + const privateKey = createPrivateKey(privateKeyPem); + const publicKey = createPublicKey(certificatePem); + + const signature = sign(null, message, privateKey); + const isValid = verify(null, message, publicKey, signature); + + if (!isValid) { + throw new BadRequestError({ message: "Private key does not match certificate" }); + } + } catch (err) { + throw new BadRequestError({ message: "Invalid private key format" }); + } + } + + // Get certificate attributes + const commonName = Array.from(certObj.subjectName.getField("CN")?.values() || [])[0] || ""; + + let altNames: undefined | string; + const sanExtension = certObj.extensions.find((ext) => ext.type === "2.5.29.17"); + if (sanExtension) { + const sanNames = new x509.GeneralNames(sanExtension.value); + altNames = sanNames.items.map((name) => name.value).join(", "); + } + + const { serialNumber, notBefore, notAfter } = certObj; + + // Encrypt certificate for storage + const certificateManagerKeyId = await getProjectKmsCertificateKeyId({ + projectId, + projectDAL, + kmsService + }); + const kmsEncryptor = await kmsService.encryptWithKmsKey({ + kmsId: certificateManagerKeyId + }); + + const { cipherTextBlob: encryptedCertificate } = await kmsEncryptor({ + plainText: Buffer.from(certificatePem) + }); + + let encryptedCertificateChain: undefined | Buffer; + if (chainPem) { + const { cipherTextBlob } = await kmsEncryptor({ + plainText: Buffer.from(chainPem) + }); + encryptedCertificateChain = cipherTextBlob; + } + + console.log(friendlyName, commonName, altNames, serialNumber, notBefore, notAfter); + + // Store in database + await certificateDAL.transaction(async (tx) => { + const cert = await certificateDAL.create( + { + status: CertStatus.ACTIVE, + friendlyName: friendlyName || commonName, + commonName, + altNames, + serialNumber, + notBefore, + notAfter + // keyUsages, + // extendedKeyUsages + }, + tx + ); + + await certificateBodyDAL.create( + { + certId: cert.id, + encryptedCertificate, + encryptedCertificateChain + }, + tx + ); + + if (collectionId) { + await pkiCollectionItemDAL.create( + { + pkiCollectionId: collectionId, + certId: cert.id + }, + tx + ); + } + + return cert; + }); + + return { + certificate: certificatePem, + certificateChain: chainPem, + privateKey: privateKeyPem, + serialNumber + }; + }; + return { getCert, deleteCert, revokeCert, - getCertBody + getCertBody, + importCert }; }; diff --git a/backend/src/services/certificate/certificate-types.ts b/backend/src/services/certificate/certificate-types.ts index ef63f142d..228597661 100644 --- a/backend/src/services/certificate/certificate-types.ts +++ b/backend/src/services/certificate/certificate-types.ts @@ -73,3 +73,14 @@ export type TRevokeCertDTO = { export type TGetCertBodyDTO = { serialNumber: string; } & Omit; + +export type TImportCertDTO = { + projectSlug: string; + + friendlyName?: string; + pkiCollectionId?: string; + + certificatePem: string; + privateKeyPem?: string; + chainPem?: string; +} & Omit; diff --git a/frontend/src/hooks/api/certificates/index.tsx b/frontend/src/hooks/api/certificates/index.tsx index dd922fd6a..2a79b647c 100644 --- a/frontend/src/hooks/api/certificates/index.tsx +++ b/frontend/src/hooks/api/certificates/index.tsx @@ -1,2 +1,2 @@ -export { useDeleteCert, useRevokeCert } from "./mutations"; +export { useDeleteCert, useRevokeCert, useImportCertificate } from "./mutations"; export { useGetCert, useGetCertBody } from "./queries"; diff --git a/frontend/src/hooks/api/certificates/mutations.tsx b/frontend/src/hooks/api/certificates/mutations.tsx index 7e9cf4f91..f231b7963 100644 --- a/frontend/src/hooks/api/certificates/mutations.tsx +++ b/frontend/src/hooks/api/certificates/mutations.tsx @@ -3,7 +3,13 @@ import { useMutation, useQueryClient } from "@tanstack/react-query"; import { apiRequest } from "@app/config/request"; import { workspaceKeys } from "../workspace"; -import { TCertificate, TDeleteCertDTO, TRevokeCertDTO } from "./types"; +import { + TCertificate, + TDeleteCertDTO, + TImportCertificateDTO, + TImportCertificateResponse, + TRevokeCertDTO +} from "./types"; export const useDeleteCert = () => { const queryClient = useQueryClient(); @@ -45,3 +51,21 @@ export const useRevokeCert = () => { } }); }; + +export const useImportCertificate = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async (body) => { + const { data } = await apiRequest.post( + "/api/v1/pki/certificates/import-certificate", + body + ); + return data; + }, + onSuccess: (_, { projectSlug }) => { + queryClient.invalidateQueries({ + queryKey: workspaceKeys.forWorkspaceCertificates(projectSlug) + }); + } + }); +}; diff --git a/frontend/src/hooks/api/certificates/types.ts b/frontend/src/hooks/api/certificates/types.ts index a9bcf5fbc..263f9d2be 100644 --- a/frontend/src/hooks/api/certificates/types.ts +++ b/frontend/src/hooks/api/certificates/types.ts @@ -25,3 +25,23 @@ export type TRevokeCertDTO = { serialNumber: string; revocationReason: string; }; + +export type TImportCertificateDTO = { + projectSlug: string; + + certificatePem: string; + privateKeyPem?: string; + chainPem?: string; + + pkiCollectionId?: string; + friendlyName?: string; +}; + +// TODO(andrey): Change this +export type TImportCertificateResponse = { + certificate: string; + issuingCertificate: string; + certificateChain: string; + privateKey: string; + serialNumber: string; +}; diff --git a/frontend/src/pages/cert-manager/CertificatesPage/components/CertificateImportModal.tsx b/frontend/src/pages/cert-manager/CertificatesPage/components/CertificateImportModal.tsx new file mode 100644 index 000000000..6f988c17f --- /dev/null +++ b/frontend/src/pages/cert-manager/CertificatesPage/components/CertificateImportModal.tsx @@ -0,0 +1,223 @@ +import { useState } from "react"; +import { Controller, useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; + +import { createNotification } from "@app/components/notifications"; +import { + Button, + FormControl, + Input, + Modal, + ModalContent, + Select, + SelectItem +} from "@app/components/v2"; +import { useWorkspace } from "@app/context"; +import { useImportCertificate, useGetCert, useListWorkspacePkiCollections } from "@app/hooks/api"; +import { UsePopUpState } from "@app/hooks/usePopUp"; + +import { CertificateContent } from "./CertificateContent"; + +const schema = z.object({ + certificatePem: z.string().trim().min(1, "Certificate PEM is required"), + privateKeyPem: z.string().trim().optional(), + chainPem: z.string().trim().optional(), + + friendlyName: z.string(), + collectionId: z.string().optional() + + // Can be added as override fields in the future | Also edit /frontend/src/hooks/api/ca/types.ts + // commonName: z.string().trim().min(1), + // altNames: z.string(), + + // Can be added as override fields in the future | Also edit /frontend/src/hooks/api/ca/types.ts + // keyUsages: z.object({ + // [CertKeyUsage.DIGITAL_SIGNATURE]: z.boolean().optional(), + // [CertKeyUsage.KEY_ENCIPHERMENT]: z.boolean().optional(), + // [CertKeyUsage.NON_REPUDIATION]: z.boolean().optional(), + // [CertKeyUsage.DATA_ENCIPHERMENT]: z.boolean().optional(), + // [CertKeyUsage.KEY_AGREEMENT]: z.boolean().optional(), + // [CertKeyUsage.KEY_CERT_SIGN]: z.boolean().optional(), + // [CertKeyUsage.CRL_SIGN]: z.boolean().optional(), + // [CertKeyUsage.ENCIPHER_ONLY]: z.boolean().optional(), + // [CertKeyUsage.DECIPHER_ONLY]: z.boolean().optional() + // }), + // extendedKeyUsages: z.object({ + // [CertExtendedKeyUsage.CLIENT_AUTH]: z.boolean().optional(), + // [CertExtendedKeyUsage.CODE_SIGNING]: z.boolean().optional(), + // [CertExtendedKeyUsage.EMAIL_PROTECTION]: z.boolean().optional(), + // [CertExtendedKeyUsage.OCSP_SIGNING]: z.boolean().optional(), + // [CertExtendedKeyUsage.SERVER_AUTH]: z.boolean().optional(), + // [CertExtendedKeyUsage.TIMESTAMPING]: z.boolean().optional() + // }) +}); + +export type FormData = z.infer; + +type Props = { + popUp: UsePopUpState<["certificateImport"]>; + handlePopUpToggle: ( + popUpName: keyof UsePopUpState<["certificateImport"]>, + state?: boolean + ) => void; +}; + +type TCertificateDetails = { + serialNumber: string; + certificate: string; + certificateChain: string; + privateKey: string; +}; + +export const CertificateImportModal = ({ popUp, handlePopUpToggle }: Props) => { + const [certificateDetails, setCertificateDetails] = useState(null); + const { currentWorkspace } = useWorkspace(); + const { data: cert } = useGetCert( + (popUp?.certificateImport?.data as { serialNumber: string })?.serialNumber || "" + ); + + const { data } = useListWorkspacePkiCollections({ + workspaceId: currentWorkspace?.id || "" + }); + + const { mutateAsync: importCertificate } = useImportCertificate(); + + const { + control, + handleSubmit, + reset, + formState: { isSubmitting } + } = useForm({ + resolver: zodResolver(schema) + }); + + const onFormSubmit = async ({ + certificatePem, + privateKeyPem, + chainPem, + friendlyName, + collectionId + }: FormData) => { + try { + if (!currentWorkspace?.slug) return; + + const { serialNumber, certificate, certificateChain, privateKey } = await importCertificate({ + projectSlug: currentWorkspace.slug, + projectSlug: currentWorkspace.slug, + + certificatePem, + privateKeyPem, + chainPem, + + friendlyName, + pkiCollectionId: collectionId + }); + + reset(); + + setCertificateDetails({ + serialNumber, + certificate, + certificateChain, + privateKey + }); + + createNotification({ + text: "Successfully imported certificate", + type: "success" + }); + } catch (err) { + console.error(err); + createNotification({ + text: "Failed to import certificate", + type: "error" + }); + } + }; + + return ( + { + handlePopUpToggle("certificateImport", isOpen); + reset(); + setCertificateDetails(null); + }} + > + + {!certificateDetails ? ( +
+ ( + + + + )} + /> + ( + + + + )} + /> + {!cert && ( +
+ + +
+ )} + + ) : ( + + )} +
+
+ ); +}; diff --git a/frontend/src/pages/cert-manager/CertificatesPage/components/CertificatesSection.tsx b/frontend/src/pages/cert-manager/CertificatesPage/components/CertificatesSection.tsx index d5f94e7b7..7a4533d3a 100644 --- a/frontend/src/pages/cert-manager/CertificatesPage/components/CertificatesSection.tsx +++ b/frontend/src/pages/cert-manager/CertificatesPage/components/CertificatesSection.tsx @@ -1,4 +1,4 @@ -import { faPlus } from "@fortawesome/free-solid-svg-icons"; +import { faArrowRight, faPlus } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { createNotification } from "@app/components/notifications"; @@ -12,6 +12,7 @@ import { CertificateCertModal } from "./CertificateCertModal"; import { CertificateModal } from "./CertificateModal"; import { CertificateRevocationModal } from "./CertificateRevocationModal"; import { CertificatesTable } from "./CertificatesTable"; +import { CertificateImportModal } from "./CertificateImportModal"; export const CertificatesSection = () => { const { currentWorkspace } = useWorkspace(); @@ -19,6 +20,7 @@ export const CertificatesSection = () => { const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([ "certificate", + "certificateImport", "certificateCert", "deleteCertificate", "revokeCertificate" @@ -54,20 +56,31 @@ export const CertificatesSection = () => { a={ProjectPermissionSub.Certificates} > {(isAllowed) => ( - +
+ + +
)} + Date: Tue, 29 Apr 2025 19:26:07 -0400 Subject: [PATCH 02/47] checkpoint frontend --- .../src/hooks/api/certificates/mutations.tsx | 1 + .../components/CertificateImportModal.tsx | 64 ++++++++++++++++++- 2 files changed, 62 insertions(+), 3 deletions(-) diff --git a/frontend/src/hooks/api/certificates/mutations.tsx b/frontend/src/hooks/api/certificates/mutations.tsx index f231b7963..88cdc3c38 100644 --- a/frontend/src/hooks/api/certificates/mutations.tsx +++ b/frontend/src/hooks/api/certificates/mutations.tsx @@ -56,6 +56,7 @@ export const useImportCertificate = () => { const queryClient = useQueryClient(); return useMutation({ mutationFn: async (body) => { + console.log("DBG-1"); const { data } = await apiRequest.post( "/api/v1/pki/certificates/import-certificate", body diff --git a/frontend/src/pages/cert-manager/CertificatesPage/components/CertificateImportModal.tsx b/frontend/src/pages/cert-manager/CertificatesPage/components/CertificateImportModal.tsx index 6f988c17f..0c921f8fc 100644 --- a/frontend/src/pages/cert-manager/CertificatesPage/components/CertificateImportModal.tsx +++ b/frontend/src/pages/cert-manager/CertificatesPage/components/CertificateImportModal.tsx @@ -11,7 +11,8 @@ import { Modal, ModalContent, Select, - SelectItem + SelectItem, + TextArea } from "@app/components/v2"; import { useWorkspace } from "@app/context"; import { useImportCertificate, useGetCert, useListWorkspacePkiCollections } from "@app/hooks/api"; @@ -104,7 +105,6 @@ export const CertificateImportModal = ({ popUp, handlePopUpToggle }: Props) => { const { serialNumber, certificate, certificateChain, privateKey } = await importCertificate({ projectSlug: currentWorkspace.slug, - projectSlug: currentWorkspace.slug, certificatePem, privateKeyPem, @@ -153,9 +153,10 @@ export const CertificateImportModal = ({ popUp, handlePopUpToggle }: Props) => { name="collectionId" render={({ field: { onChange, ...field }, fieldState: { error } }) => (