diff --git a/backend/src/db/migrations/20250429203304_certificates-ca-relation-removal.ts b/backend/src/db/migrations/20250429203304_certificates-ca-relation-removal.ts new file mode 100644 index 000000000..1137b9ab8 --- /dev/null +++ b/backend/src/db/migrations/20250429203304_certificates-ca-relation-removal.ts @@ -0,0 +1,44 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + if (await knex.schema.hasTable(TableName.Certificate)) { + const hasProjectIdColumn = await knex.schema.hasColumn(TableName.Certificate, "projectId"); + if (!hasProjectIdColumn) { + await knex.schema.alterTable(TableName.Certificate, (t) => { + t.string("projectId", 36).nullable(); + t.foreign("projectId").references("id").inTable(TableName.Project).onDelete("CASCADE"); + }); + + await knex.raw(` + UPDATE "${TableName.Certificate}" cert + SET "projectId" = ca."projectId" + FROM "${TableName.CertificateAuthority}" ca + WHERE cert."caId" = ca.id + `); + + await knex.schema.alterTable(TableName.Certificate, (t) => { + t.string("projectId").notNullable().alter(); + }); + } + + await knex.schema.alterTable(TableName.Certificate, (t) => { + t.uuid("caId").nullable().alter(); + t.uuid("caCertId").nullable().alter(); + }); + } +} + +export async function down(knex: Knex): Promise { + if (await knex.schema.hasTable(TableName.Certificate)) { + if (await knex.schema.hasColumn(TableName.Certificate, "projectId")) { + await knex.schema.alterTable(TableName.Certificate, (t) => { + t.dropForeign("projectId"); + t.dropColumn("projectId"); + }); + } + } + + // Altering back to notNullable for caId and caCertId will fail +} diff --git a/backend/src/db/schemas/certificates.ts b/backend/src/db/schemas/certificates.ts index cbd4f64f9..6bedf01ad 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,10 +21,11 @@ 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(), + projectId: z.string(), pkiSubscriberId: z.string().uuid().nullable().optional() }); 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 5b022aae8..175a7dd57 100644 --- a/backend/src/ee/services/audit-log/audit-log-types.ts +++ b/backend/src/ee/services/audit-log/audit-log-types.ts @@ -235,6 +235,7 @@ export enum EventType { IMPORT_CA_CERT = "import-certificate-authority-cert", GET_CA_CRLS = "get-certificate-authority-crls", ISSUE_CERT = "issue-cert", + IMPORT_CERT = "import-cert", SIGN_CERT = "sign-cert", GET_CA_CERTIFICATE_TEMPLATES = "get-ca-certificate-templates", GET_CERT = "get-cert", @@ -1812,6 +1813,15 @@ interface IssueCert { }; } +interface ImportCert { + type: EventType.IMPORT_CERT; + metadata: { + certId: string; + cn: string; + serialNumber: string; + }; +} + interface SignCert { type: EventType.SIGN_CERT; metadata: { @@ -2987,6 +2997,7 @@ export type Event = | ImportCaCert | GetCaCrls | IssueCert + | ImportCert | SignCert | GetCaCertificateTemplates | GetCert diff --git a/backend/src/lib/api-docs/constants.ts b/backend/src/lib/api-docs/constants.ts index 34c701507..9183751b1 100644 --- a/backend/src/lib/api-docs/constants.ts +++ b/backend/src/lib/api-docs/constants.ts @@ -1669,6 +1669,19 @@ export const CERTIFICATES = { certificateChain: "The certificate chain of the certificate.", serialNumberRes: "The serial number of the certificate.", privateKey: "The private key 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/index.ts b/backend/src/server/routes/index.ts index ae6b9e512..919c74733 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -847,7 +847,9 @@ export const registerRoutes = async ( certificateAuthoritySecretDAL, projectDAL, kmsService, - permissionService + permissionService, + pkiCollectionDAL, + pkiCollectionItemDAL }); const sshCertificateAuthorityService = sshCertificateAuthorityServiceFactory({ diff --git a/backend/src/server/routes/v1/certificate-router.ts b/backend/src/server/routes/v1/certificate-router.ts index 067055587..9cc17274c 100644 --- a/backend/src/server/routes/v1/certificate-router.ts +++ b/backend/src/server/routes/v1/certificate-router.ts @@ -39,7 +39,7 @@ export const registerCertRouter = async (server: FastifyZodProvider) => { } }, handler: async (req) => { - const { cert, ca } = await server.services.certificate.getCert({ + const { cert } = await server.services.certificate.getCert({ serialNumber: req.params.serialNumber, actor: req.permission.type, actorId: req.permission.id, @@ -49,7 +49,7 @@ export const registerCertRouter = async (server: FastifyZodProvider) => { await server.services.auditLog.createAuditLog({ ...req.auditLogInfo, - projectId: ca.projectId, + projectId: cert.projectId, event: { type: EventType.GET_CERT, metadata: { @@ -86,7 +86,7 @@ export const registerCertRouter = async (server: FastifyZodProvider) => { } }, handler: async (req, reply) => { - const { ca, cert, certPrivateKey } = await server.services.certificate.getCertPrivateKey({ + const { cert, certPrivateKey } = await server.services.certificate.getCertPrivateKey({ serialNumber: req.params.serialNumber, actor: req.permission.type, actorId: req.permission.id, @@ -96,7 +96,7 @@ export const registerCertRouter = async (server: FastifyZodProvider) => { await server.services.auditLog.createAuditLog({ ...req.auditLogInfo, - projectId: ca.projectId, + projectId: cert.projectId, event: { type: EventType.GET_CERT_PRIVATE_KEY, metadata: { @@ -131,14 +131,14 @@ export const registerCertRouter = async (server: FastifyZodProvider) => { response: { 200: z.object({ certificate: z.string().trim().describe(CERTIFICATES.GET_CERT.certificate), - certificateChain: z.string().trim().nullish().describe(CERTIFICATES.GET_CERT.certificateChain), + certificateChain: z.string().trim().nullable().describe(CERTIFICATES.GET_CERT.certificateChain), privateKey: z.string().trim().describe(CERTIFICATES.GET_CERT.privateKey), serialNumber: z.string().trim().describe(CERTIFICATES.GET_CERT.serialNumberRes) }) } }, handler: async (req, reply) => { - const { certificate, certificateChain, serialNumber, cert, ca, privateKey } = + const { certificate, certificateChain, serialNumber, cert, privateKey } = await server.services.certificate.getCertBundle({ serialNumber: req.params.serialNumber, actor: req.permission.type, @@ -149,7 +149,7 @@ export const registerCertRouter = async (server: FastifyZodProvider) => { await server.services.auditLog.createAuditLog({ ...req.auditLogInfo, - projectId: ca.projectId, + projectId: cert.projectId, event: { type: EventType.GET_CERT_BUNDLE, metadata: { @@ -284,6 +284,68 @@ 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().min(1).describe(CERTIFICATES.IMPORT.privateKeyPem), + chainPem: z.string().trim().min(1).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().describe(CERTIFICATES.IMPORT.certificateChain), + privateKey: z.string().trim().describe(CERTIFICATES.IMPORT.privateKey), + serialNumber: z.string().trim().describe(CERTIFICATES.IMPORT.serialNumber) + }) + } + }, + handler: async (req) => { + const { certificate, certificateChain, privateKey, serialNumber, cert } = + await server.services.certificate.importCert({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + ...req.body + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: cert.projectId, + event: { + type: EventType.IMPORT_CERT, + metadata: { + certId: cert.id, + cn: cert.commonName, + serialNumber + } + } + }); + + return { + certificate, + certificateChain, + privateKey, + serialNumber + }; + } + }); + server.route({ method: "POST", url: "/sign-certificate", @@ -474,7 +536,7 @@ export const registerCertRouter = async (server: FastifyZodProvider) => { } }, handler: async (req) => { - const { deletedCert, ca } = await server.services.certificate.deleteCert({ + const { deletedCert } = await server.services.certificate.deleteCert({ serialNumber: req.params.serialNumber, actor: req.permission.type, actorId: req.permission.id, @@ -484,7 +546,7 @@ export const registerCertRouter = async (server: FastifyZodProvider) => { await server.services.auditLog.createAuditLog({ ...req.auditLogInfo, - projectId: ca.projectId, + projectId: deletedCert.projectId, event: { type: EventType.DELETE_CERT, metadata: { @@ -518,13 +580,13 @@ export const registerCertRouter = async (server: FastifyZodProvider) => { response: { 200: z.object({ certificate: z.string().trim().describe(CERTIFICATES.GET_CERT.certificate), - certificateChain: z.string().trim().nullish().describe(CERTIFICATES.GET_CERT.certificateChain), + certificateChain: z.string().trim().nullable().describe(CERTIFICATES.GET_CERT.certificateChain), serialNumber: z.string().trim().describe(CERTIFICATES.GET_CERT.serialNumberRes) }) } }, handler: async (req) => { - const { certificate, certificateChain, serialNumber, cert, ca } = await server.services.certificate.getCertBody({ + const { certificate, certificateChain, serialNumber, cert } = await server.services.certificate.getCertBody({ serialNumber: req.params.serialNumber, actor: req.permission.type, actorId: req.permission.id, @@ -534,7 +596,7 @@ export const registerCertRouter = async (server: FastifyZodProvider) => { await server.services.auditLog.createAuditLog({ ...req.auditLogInfo, - projectId: ca.projectId, + projectId: cert.projectId, event: { type: EventType.GET_CERT_BODY, metadata: { diff --git a/backend/src/services/certificate/certificate-fns.ts b/backend/src/services/certificate/certificate-fns.ts index 961fb27ff..9fe77de21 100644 --- a/backend/src/services/certificate/certificate-fns.ts +++ b/backend/src/services/certificate/certificate-fns.ts @@ -5,7 +5,7 @@ import * as x509 from "@peculiar/x509"; import { BadRequestError, NotFoundError } from "@app/lib/errors"; import { getProjectKmsCertificateKeyId } from "../project/project-fns"; -import { CrlReason, TBuildCertificateChainDTO, TGetCertificateCredentialsDTO } from "./certificate-types"; +import { CrlReason, TGetCertificateCredentialsDTO } from "./certificate-types"; export const revocationReasonToCrlCode = (crlReason: CrlReason) => { switch (crlReason) { @@ -52,6 +52,9 @@ export const constructPemChainFromCerts = (certificates: x509.X509Certificate[]) .join("\n") .trim(); +export const splitPemChain = (pemText: string) => + pemText.match(/-----BEGIN CERTIFICATE-----[^-]+-----END CERTIFICATE-----/g) || []; + /** * Return the public and private key of certificate * Note: credentials are returned as PEM strings @@ -95,29 +98,3 @@ export const getCertificateCredentials = async ({ throw new BadRequestError({ message: `Failed to process private key for certificate with ID '${certId}'` }); } }; - -// If the certificate was generated after ~05/01/25 it will have a encryptedCertificateChain attached to it's body -// Otherwise we'll fallback to manually building the chain -export const buildCertificateChain = async ({ - caCert, - caCertChain, - encryptedCertificateChain, - kmsService, - kmsId -}: TBuildCertificateChainDTO) => { - if (!encryptedCertificateChain && (!caCert || !caCertChain)) { - return null; - } - - let certificateChain = `${caCert}\n${caCertChain}`.trim(); - - if (encryptedCertificateChain) { - const kmsDecryptor = await kmsService.decryptWithKmsKey({ kmsId }); - const decryptedCertChain = await kmsDecryptor({ - cipherTextBlob: encryptedCertificateChain - }); - certificateChain = decryptedCertChain.toString(); - } - - return certificateChain; -}; diff --git a/backend/src/services/certificate/certificate-service.ts b/backend/src/services/certificate/certificate-service.ts index f10ec1c4d..3ce80e635 100644 --- a/backend/src/services/certificate/certificate-service.ts +++ b/backend/src/services/certificate/certificate-service.ts @@ -1,19 +1,23 @@ 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 { ProjectPermissionCertificateActions, 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"; @@ -21,12 +25,16 @@ import { expandInternalCa, getCaCertChain, rebuildCaCrl } from "../certificate-a import { buildCertificateChain, getCertificateCredentials, revocationReasonToCrlCode } from "./certificate-fns"; import { TCertificateSecretDALFactory } from "./certificate-secret-dal"; import { + CertExtendedKeyUsage, + CertExtendedKeyUsageOIDToName, + CertKeyUsage, CertStatus, TDeleteCertDTO, TGetCertBodyDTO, TGetCertBundleDTO, TGetCertDTO, TGetCertPrivateKeyDTO, + TImportCertDTO, TRevokeCertDTO } from "./certificate-types"; @@ -38,7 +46,12 @@ type TCertificateServiceFactoryDep = { 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; }; @@ -53,6 +66,8 @@ export const certificateServiceFactory = ({ certificateAuthorityCertDAL, certificateAuthorityCrlDAL, certificateAuthoritySecretDAL, + pkiCollectionDAL, + pkiCollectionItemDAL, projectDAL, kmsService, permissionService @@ -67,7 +82,7 @@ export const certificateServiceFactory = ({ const { permission } = await permissionService.getProjectPermission({ actor, actorId, - projectId: ca.projectId, + projectId: cert.projectId, actorAuthMethod, actorOrgId, actionProjectType: ActionProjectType.CertificateManager @@ -100,7 +115,7 @@ export const certificateServiceFactory = ({ const { permission } = await permissionService.getProjectPermission({ actor, actorId, - projectId: ca.projectId, + projectId: cert.projectId, actorAuthMethod, actorOrgId, actionProjectType: ActionProjectType.CertificateManager @@ -113,7 +128,7 @@ export const certificateServiceFactory = ({ const { certPrivateKey } = await getCertificateCredentials({ certId: cert.id, - projectId: ca.projectId, + projectId: cert.projectId, certificateSecretDAL, projectDAL, kmsService @@ -136,7 +151,7 @@ export const certificateServiceFactory = ({ const { permission } = await permissionService.getProjectPermission({ actor, actorId, - projectId: ca.projectId, + projectId: cert.projectId, actorAuthMethod, actorOrgId, actionProjectType: ActionProjectType.CertificateManager @@ -224,7 +239,7 @@ export const certificateServiceFactory = ({ const { permission } = await permissionService.getProjectPermission({ actor, actorId, - projectId: ca.projectId, + projectId: cert.projectId, actorAuthMethod, actorOrgId, actionProjectType: ActionProjectType.CertificateManager @@ -238,7 +253,7 @@ export const certificateServiceFactory = ({ const certBody = await certificateBodyDAL.findOne({ certId: cert.id }); const certificateManagerKeyId = await getProjectKmsCertificateKeyId({ - projectId: ca.projectId, + projectId: cert.projectId, projectDAL, kmsService }); @@ -252,21 +267,26 @@ export const certificateServiceFactory = ({ const certObj = new x509.X509Certificate(decryptedCert); - const { caCert, caCertChain } = await getCaCertChain({ - caCertId: cert.caCertId, - certificateAuthorityDAL, - certificateAuthorityCertDAL, - projectDAL, - kmsService - }); + let certificateChain = null; - const certificateChain = await buildCertificateChain({ - caCert, - caCertChain, - kmsId: certificateManagerKeyId, - kmsService, - encryptedCertificateChain: certBody.encryptedCertificateChain || undefined - }); + // On newer certs the certBody.encryptedCertificateChain column will always exist. + // Older certs will have a caCertId which will be used as a fallback mechanism for structuring the chain. + if (certBody.encryptedCertificateChain) { + const decryptedCertChain = await kmsDecryptor({ + cipherTextBlob: certBody.encryptedCertificateChain + }); + certificateChain = decryptedCertChain.toString(); + } else if (cert.caCertId) { + const { caCert, caCertChain } = await getCaCertChain({ + caCertId: cert.caCertId, + certificateAuthorityDAL, + certificateAuthorityCertDAL, + projectDAL, + kmsService + }); + + certificateChain = `${caCert}\n${caCertChain}`.trim(); + } return { certificate: certObj.toString("pem"), @@ -288,7 +308,7 @@ export const certificateServiceFactory = ({ const { permission } = await permissionService.getProjectPermission({ actor, actorId, - projectId: ca.projectId, + projectId: cert.projectId, actorAuthMethod, actorOrgId, actionProjectType: ActionProjectType.CertificateManager @@ -306,7 +326,7 @@ export const certificateServiceFactory = ({ const certBody = await certificateBodyDAL.findOne({ certId: cert.id }); const certificateManagerKeyId = await getProjectKmsCertificateKeyId({ - projectId: ca.projectId, + projectId: cert.projectId, projectDAL, kmsService }); @@ -321,25 +341,30 @@ export const certificateServiceFactory = ({ const certObj = new x509.X509Certificate(decryptedCert); const certificate = certObj.toString("pem"); - const { caCert, caCertChain } = await getCaCertChain({ - caCertId: cert.caCertId, - certificateAuthorityDAL, - certificateAuthorityCertDAL, - projectDAL, - kmsService - }); + let certificateChain = null; - const certificateChain = await buildCertificateChain({ - caCert, - caCertChain, - kmsId: certificateManagerKeyId, - kmsService, - encryptedCertificateChain: certBody.encryptedCertificateChain || undefined - }); + // On newer certs the certBody.encryptedCertificateChain column will always exist. + // Older certs will have a caCertId which will be used as a fallback mechanism for structuring the chain. + if (certBody.encryptedCertificateChain) { + const decryptedCertChain = await kmsDecryptor({ + cipherTextBlob: certBody.encryptedCertificateChain + }); + certificateChain = decryptedCertChain.toString(); + } else if (cert.caCertId) { + const { caCert, caCertChain } = await getCaCertChain({ + caCertId: cert.caCertId, + certificateAuthorityDAL, + certificateAuthorityCertDAL, + projectDAL, + kmsService + }); + + certificateChain = `${caCert}\n${caCertChain}`.trim(); + } const { certPrivateKey } = await getCertificateCredentials({ certId: cert.id, - projectId: ca.projectId, + projectId: cert.projectId, certificateSecretDAL, projectDAL, kmsService @@ -361,6 +386,7 @@ export const certificateServiceFactory = ({ deleteCert, revokeCert, getCertBody, + importCert, getCertBundle }; }; diff --git a/backend/src/services/certificate/certificate-types.ts b/backend/src/services/certificate/certificate-types.ts index ae04eae6b..f1c79a36f 100644 --- a/backend/src/services/certificate/certificate-types.ts +++ b/backend/src/services/certificate/certificate-types.ts @@ -78,6 +78,17 @@ export type TGetCertBodyDTO = { serialNumber: string; } & Omit; +export type TImportCertDTO = { + projectSlug: string; + + friendlyName?: string; + pkiCollectionId?: string; + + certificatePem: string; + privateKeyPem: string; + chainPem: string; +} & Omit; + export type TGetCertPrivateKeyDTO = { serialNumber: string; } & Omit; @@ -93,11 +104,3 @@ export type TGetCertificateCredentialsDTO = { projectDAL: Pick; kmsService: Pick; }; - -export type TBuildCertificateChainDTO = { - caCert?: string; - caCertChain?: string; - encryptedCertificateChain?: Buffer; - kmsService: Pick; - kmsId: string; -}; diff --git a/backend/src/services/pki-collection/pki-collection-service.ts b/backend/src/services/pki-collection/pki-collection-service.ts index bee3ee621..577441bfb 100644 --- a/backend/src/services/pki-collection/pki-collection-service.ts +++ b/backend/src/services/pki-collection/pki-collection-service.ts @@ -269,14 +269,8 @@ export const pkiCollectionServiceFactory = ({ }); if (isCertAdded) throw new BadRequestError({ message: "Certificate already part of the PKI collection" }); - // validate that there exists a certificate in same project as PKI collection - const cas = await certificateAuthorityDAL.find({ projectId: pkiCollection.projectId }); - - // TODO: consider making this more efficient const [certificate] = await certificateDAL.find({ - $in: { - caId: cas.map((ca) => ca.id) - }, + projectId: pkiCollection.projectId, id: itemId }); if (!certificate) throw new NotFoundError({ message: `Certificate with ID '${itemId}' not found` }); diff --git a/backend/src/services/pki-subscriber/pki-subscriber-service.ts b/backend/src/services/pki-subscriber/pki-subscriber-service.ts index bd1a14e50..793abeaa3 100644 --- a/backend/src/services/pki-subscriber/pki-subscriber-service.ts +++ b/backend/src/services/pki-subscriber/pki-subscriber-service.ts @@ -611,7 +611,8 @@ export const pkiSubscriberServiceFactory = ({ notBefore: notBeforeDate, notAfter: notAfterDate, keyUsages: selectedKeyUsages, - extendedKeyUsages: selectedExtendedKeyUsages + extendedKeyUsages: selectedExtendedKeyUsages, + projectId }, tx ); diff --git a/backend/src/services/project/project-service.ts b/backend/src/services/project/project-service.ts index 110cf48ef..87b4f6256 100644 --- a/backend/src/services/project/project-service.ts +++ b/backend/src/services/project/project-service.ts @@ -969,13 +969,9 @@ export const projectServiceFactory = ({ ProjectPermissionSub.Certificates ); - const cas = await certificateAuthorityDAL.find({ projectId }); - const certificates = await certificateDAL.find( { - $in: { - caId: cas.map((ca) => ca.id) - }, + projectId, ...(friendlyName && { friendlyName }), ...(commonName && { commonName }) }, diff --git a/frontend/src/hooks/api/auditLogs/constants.tsx b/frontend/src/hooks/api/auditLogs/constants.tsx index f726566cd..f84ddabe7 100644 --- a/frontend/src/hooks/api/auditLogs/constants.tsx +++ b/frontend/src/hooks/api/auditLogs/constants.tsx @@ -68,6 +68,7 @@ export const eventToNameMap: { [K in EventType]: string } = { [EventType.IMPORT_CA_CERT]: "Import CA certificate", [EventType.GET_CA_CRL]: "Get CA CRL", [EventType.ISSUE_CERT]: "Issue certificate", + [EventType.IMPORT_CERT]: "Import certificate", [EventType.GET_CERT]: "Get certificate", [EventType.DELETE_CERT]: "Delete certificate", [EventType.REVOKE_CERT]: "Revoke certificate", diff --git a/frontend/src/hooks/api/auditLogs/enums.tsx b/frontend/src/hooks/api/auditLogs/enums.tsx index b74969d6d..82f07f385 100644 --- a/frontend/src/hooks/api/auditLogs/enums.tsx +++ b/frontend/src/hooks/api/auditLogs/enums.tsx @@ -81,6 +81,7 @@ export enum EventType { IMPORT_CA_CERT = "import-certificate-authority-cert", GET_CA_CRL = "get-certificate-authority-crl", ISSUE_CERT = "issue-cert", + IMPORT_CERT = "import-cert", GET_CERT = "get-cert", DELETE_CERT = "delete-cert", REVOKE_CERT = "revoke-cert", diff --git a/frontend/src/hooks/api/auditLogs/types.tsx b/frontend/src/hooks/api/auditLogs/types.tsx index 745d0368f..838f500eb 100644 --- a/frontend/src/hooks/api/auditLogs/types.tsx +++ b/frontend/src/hooks/api/auditLogs/types.tsx @@ -583,6 +583,14 @@ interface IssueCert { serialNumber: string; }; } +interface ImportCert { + type: EventType.IMPORT_CERT; + metadata: { + certId: string; + cn: string; + serialNumber: string; + }; +} interface GetCert { type: EventType.GET_CERT; @@ -895,6 +903,7 @@ export type Event = | ImportCaCert | GetCaCrl | IssueCert + | ImportCert | GetCert | DeleteCert | RevokeCert diff --git a/frontend/src/hooks/api/certificates/index.tsx b/frontend/src/hooks/api/certificates/index.tsx index dd922fd6a..ddac04730 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, useImportCertificate, useRevokeCert } 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 74d6b5cbb..12f8e834b 100644 --- a/frontend/src/hooks/api/certificates/mutations.tsx +++ b/frontend/src/hooks/api/certificates/mutations.tsx @@ -4,7 +4,13 @@ import { apiRequest } from "@app/config/request"; import { pkiSubscriberKeys } from "../pkiSubscriber/queries"; import { workspaceKeys } from "../workspace"; -import { TCertificate, TDeleteCertDTO, TRevokeCertDTO } from "./types"; +import { + TCertificate, + TDeleteCertDTO, + TImportCertificateDTO, + TImportCertificateResponse, + TRevokeCertDTO +} from "./types"; export const useDeleteCert = () => { const queryClient = useQueryClient(); @@ -49,3 +55,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..c1dd59eca 100644 --- a/frontend/src/hooks/api/certificates/types.ts +++ b/frontend/src/hooks/api/certificates/types.ts @@ -25,3 +25,21 @@ export type TRevokeCertDTO = { serialNumber: string; revocationReason: string; }; + +export type TImportCertificateDTO = { + projectSlug: string; + + certificatePem: string; + privateKeyPem: string; + chainPem: string; + + pkiCollectionId?: string; + friendlyName?: string; +}; + +export type TImportCertificateResponse = { + certificate: 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..c4cffe7b0 --- /dev/null +++ b/frontend/src/pages/cert-manager/CertificatesPage/components/CertificateImportModal.tsx @@ -0,0 +1,244 @@ +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, + TextArea +} from "@app/components/v2"; +import { useWorkspace } from "@app/context"; +import { useGetCert, useImportCertificate, 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().min(1, "Private Key PEM is required"), + chainPem: z.string().trim().min(1, "Certificate Chain PEM is required"), + + friendlyName: z.string(), + collectionId: z.string().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, + + 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 ? ( +
+ ( + + + + )} + /> + ( + + + + )} + /> + ( + +