From b358f2dbb73060f41f35fede695e6bb56a146731 Mon Sep 17 00:00:00 2001 From: Sheen Capadngan Date: Sun, 18 May 2025 23:37:23 +0800 Subject: [PATCH] feat: added subscriber endpoint for fetching active cert --- .../ee/services/audit-log/audit-log-types.ts | 12 ++ backend/src/lib/api-docs/constants.ts | 8 ++ .../server/routes/v1/pki-subscriber-router.ts | 67 ++++++++++ .../services/certificate/certificate-dal.ts | 16 ++- .../pki-subscriber/pki-subscriber-service.ts | 119 +++++++++++++++++- .../pki-subscriber/pki-subscriber-types.ts | 4 + .../subscribers/get-active-cert-bundle.mdx | 4 + docs/mint.json | 3 +- 8 files changed, 227 insertions(+), 6 deletions(-) create mode 100644 docs/api-reference/endpoints/pki/subscribers/get-active-cert-bundle.mdx 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 17940ac55..e00fca9f5 100644 --- a/backend/src/ee/services/audit-log/audit-log-types.ts +++ b/backend/src/ee/services/audit-log/audit-log-types.ts @@ -263,6 +263,7 @@ export enum EventType { ISSUE_PKI_SUBSCRIBER_CERT = "issue-pki-subscriber-cert", SIGN_PKI_SUBSCRIBER_CERT = "sign-pki-subscriber-cert", LIST_PKI_SUBSCRIBER_CERTS = "list-pki-subscriber-certs", + GET_SUBSCRIBER_ACTIVE_CERT_BUNDLE = "get-subscriber-active-cert-bundle", CREATE_KMS = "create-kms", UPDATE_KMS = "update-kms", DELETE_KMS = "delete-kms", @@ -2061,6 +2062,16 @@ interface ListPkiSubscriberCerts { }; } +interface GetSubscriberActiveCertBundle { + type: EventType.GET_SUBSCRIBER_ACTIVE_CERT_BUNDLE; + metadata: { + subscriberId: string; + subscriberName: string; + certId: string; + serialNumber: string; + }; +} + interface CreateKmsEvent { type: EventType.CREATE_KMS; metadata: { @@ -3033,6 +3044,7 @@ export type Event = | IssuePkiSubscriberCert | SignPkiSubscriberCert | ListPkiSubscriberCerts + | GetSubscriberActiveCertBundle | CreateKmsEvent | UpdateKmsEvent | DeleteKmsEvent diff --git a/backend/src/lib/api-docs/constants.ts b/backend/src/lib/api-docs/constants.ts index 4ac675d17..79c9477ec 100644 --- a/backend/src/lib/api-docs/constants.ts +++ b/backend/src/lib/api-docs/constants.ts @@ -1755,6 +1755,14 @@ export const PKI_SUBSCRIBERS = { subscriberName: "The name of the PKI subscriber to get.", projectId: "The ID of the project to get the PKI subscriber for." }, + GET_ACTIVE_CERT_BUNDLE: { + subscriberName: "The name of the PKI subscriber to get the active certificate bundle for.", + projectId: "The ID of the project to get the active certificate bundle for.", + certificate: "The active certificate for the subscriber.", + certificateChain: "The certificate chain of the active certificate for the subscriber.", + privateKey: "The private key of the active certificate for the subscriber.", + serialNumber: "The serial number of the active certificate for the subscriber." + }, CREATE: { projectId: "The ID of the project to create the PKI subscriber in.", caId: "The ID of the CA that will issue certificates for the PKI subscriber.", diff --git a/backend/src/server/routes/v1/pki-subscriber-router.ts b/backend/src/server/routes/v1/pki-subscriber-router.ts index 77fbc3a49..12ece4d0f 100644 --- a/backend/src/server/routes/v1/pki-subscriber-router.ts +++ b/backend/src/server/routes/v1/pki-subscriber-router.ts @@ -5,6 +5,7 @@ import { EventType } from "@app/ee/services/audit-log/audit-log-types"; import { ApiDocsTags, PKI_SUBSCRIBERS } from "@app/lib/api-docs"; import { ms } from "@app/lib/ms"; import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; +import { addNoCacheHeaders } from "@app/server/lib/caching"; import { slugSchema } from "@app/server/lib/schemas"; import { getTelemetryDistinctId } from "@app/server/lib/telemetry"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; @@ -482,6 +483,72 @@ export const registerPkiSubscriberRouter = async (server: FastifyZodProvider) => } }); + server.route({ + method: "GET", + url: "/:subscriberName/active-certificate/bundle", + config: { + rateLimit: readLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.PkiSubscribers], + description: "Get active certificate bundle of a subscriber", + params: z.object({ + subscriberName: z.string().describe(PKI_SUBSCRIBERS.GET_ACTIVE_CERT_BUNDLE.subscriberName) + }), + querystring: z.object({ + projectId: z.string().trim().describe(PKI_SUBSCRIBERS.GET_ACTIVE_CERT_BUNDLE.projectId) + }), + response: { + 200: z.object({ + certificate: z.string().trim().describe(PKI_SUBSCRIBERS.GET_ACTIVE_CERT_BUNDLE.certificate), + certificateChain: z + .string() + .trim() + .nullable() + .describe(PKI_SUBSCRIBERS.GET_ACTIVE_CERT_BUNDLE.certificateChain), + privateKey: z.string().trim().describe(PKI_SUBSCRIBERS.GET_ACTIVE_CERT_BUNDLE.privateKey), + serialNumber: z.string().trim().describe(PKI_SUBSCRIBERS.GET_ACTIVE_CERT_BUNDLE.serialNumber) + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req, reply) => { + const { certificate, certificateChain, serialNumber, cert, privateKey, subscriber } = + await server.services.pkiSubscriber.getSubscriberActiveCertBundle({ + subscriberName: req.params.subscriberName, + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + ...req.query + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: cert.projectId, + event: { + type: EventType.GET_SUBSCRIBER_ACTIVE_CERT_BUNDLE, + metadata: { + subscriberId: subscriber.id, + subscriberName: subscriber.name, + certId: cert.id, + serialNumber: cert.serialNumber + } + } + }); + + addNoCacheHeaders(reply); + + return { + certificate, + certificateChain, + serialNumber, + privateKey + }; + } + }); + server.route({ method: "GET", url: "/:subscriberName/certificates", diff --git a/backend/src/services/certificate/certificate-dal.ts b/backend/src/services/certificate/certificate-dal.ts index aafbe56f4..9bad70054 100644 --- a/backend/src/services/certificate/certificate-dal.ts +++ b/backend/src/services/certificate/certificate-dal.ts @@ -3,11 +3,24 @@ import { TableName } from "@app/db/schemas"; import { DatabaseError } from "@app/lib/errors"; import { ormify } from "@app/lib/knex"; +import { CertStatus } from "./certificate-types"; + export type TCertificateDALFactory = ReturnType; export const certificateDALFactory = (db: TDbClient) => { const certificateOrm = ormify(db, TableName.Certificate); + const findLatestActiveCertForSubscriber = async ({ subscriberId }: { subscriberId: string }) => { + const cert = await db + .replicaNode()(TableName.Certificate) + .where({ pkiSubscriberId: subscriberId, status: CertStatus.ACTIVE }) + .where("notAfter", ">", new Date()) + .orderBy("notBefore", "desc") + .first(); + + return cert; + }; + const countCertificatesInProject = async ({ projectId, friendlyName, @@ -65,6 +78,7 @@ export const certificateDALFactory = (db: TDbClient) => { return { ...certificateOrm, countCertificatesInProject, - countCertificatesForPkiSubscriber + countCertificatesForPkiSubscriber, + findLatestActiveCertForSubscriber }; }; diff --git a/backend/src/services/pki-subscriber/pki-subscriber-service.ts b/backend/src/services/pki-subscriber/pki-subscriber-service.ts index 2cb80bd0f..7bcc545cb 100644 --- a/backend/src/services/pki-subscriber/pki-subscriber-service.ts +++ b/backend/src/services/pki-subscriber/pki-subscriber-service.ts @@ -6,6 +6,7 @@ import { ActionProjectType } 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, ProjectPermissionPkiSubscriberActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; @@ -38,6 +39,7 @@ import { TPkiSubscriberDALFactory } from "@app/services/pki-subscriber/pki-subsc import { TProjectDALFactory } from "@app/services/project/project-dal"; import { getProjectKmsCertificateKeyId } from "@app/services/project/project-fns"; +import { getCertificateCredentials } from "../certificate/certificate-fns"; import { TCertificateSecretDALFactory } from "../certificate/certificate-secret-dal"; import { TCertificateAuthorityQueueFactory } from "../certificate-authority/certificate-authority-queue"; import { InternalCertificateAuthorityFns } from "../certificate-authority/internal/internal-certificate-authority-fns"; @@ -46,6 +48,7 @@ import { TCreatePkiSubscriberDTO, TDeletePkiSubscriberDTO, TGetPkiSubscriberDTO, + TGetSubscriberActiveCertBundleDTO, TIssuePkiSubscriberCertDTO, TListPkiSubscriberCertsDTO, TOrderPkiSubscriberCertDTO, @@ -66,9 +69,12 @@ type TPkiSubscriberServiceFactoryDep = { certificateAuthoritySecretDAL: Pick; certificateAuthorityQueue: Pick; certificateAuthorityCrlDAL: Pick; - certificateDAL: Pick; - certificateSecretDAL: Pick; - certificateBodyDAL: Pick; + certificateDAL: Pick< + TCertificateDALFactory, + "create" | "transaction" | "countCertificatesForPkiSubscriber" | "findLatestActiveCertForSubscriber" | "find" + >; + certificateSecretDAL: Pick; + certificateBodyDAL: Pick; projectDAL: Pick; kmsService: Pick; permissionService: Pick; @@ -691,6 +697,110 @@ export const pkiSubscriberServiceFactory = ({ }; }; + const getSubscriberActiveCertBundle = async ({ + subscriberName, + projectId, + actorId, + actorAuthMethod, + actor, + actorOrgId + }: TGetSubscriberActiveCertBundleDTO) => { + const subscriber = await pkiSubscriberDAL.findOne({ + name: subscriberName, + projectId + }); + + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId: subscriber.projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.CertificateManager + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionPkiSubscriberActions.ListCerts, + subject(ProjectPermissionSub.PkiSubscribers, { + name: subscriber.name + }) + ); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionCertificateActions.Read, + ProjectPermissionSub.Certificates + ); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionCertificateActions.ReadPrivateKey, + ProjectPermissionSub.Certificates + ); + + const cert = await certificateDAL.findLatestActiveCertForSubscriber({ + subscriberId: subscriber.id + }); + + if (!cert) { + throw new NotFoundError({ message: "No active certificate found for subscriber" }); + } + + const certBody = await certificateBodyDAL.findOne({ certId: cert.id }); + + const certificateManagerKeyId = await getProjectKmsCertificateKeyId({ + projectId: cert.projectId, + projectDAL, + kmsService + }); + + const kmsDecryptor = await kmsService.decryptWithKmsKey({ + kmsId: certificateManagerKeyId + }); + const decryptedCert = await kmsDecryptor({ + cipherTextBlob: certBody.encryptedCertificate + }); + + const certObj = new x509.X509Certificate(decryptedCert); + const certificate = certObj.toString("pem"); + + let certificateChain = null; + + // 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: cert.projectId, + certificateSecretDAL, + projectDAL, + kmsService + }); + + return { + certificate, + certificateChain, + privateKey: certPrivateKey, + serialNumber: cert.serialNumber, + cert, + subscriber + }; + }; + return { createSubscriber, getSubscriber, @@ -699,6 +809,7 @@ export const pkiSubscriberServiceFactory = ({ issueSubscriberCert, signSubscriberCert, listSubscriberCerts, - orderSubscriberCert + orderSubscriberCert, + getSubscriberActiveCertBundle }; }; diff --git a/backend/src/services/pki-subscriber/pki-subscriber-types.ts b/backend/src/services/pki-subscriber/pki-subscriber-types.ts index d97fbe5f5..050c520ad 100644 --- a/backend/src/services/pki-subscriber/pki-subscriber-types.ts +++ b/backend/src/services/pki-subscriber/pki-subscriber-types.ts @@ -57,6 +57,10 @@ export type TListPkiSubscriberCertsDTO = { limit: number; } & TProjectPermission; +export type TGetSubscriberActiveCertBundleDTO = { + subscriberName: string; +} & TProjectPermission; + export enum SubscriberOperationStatus { SUCCESS = "success", FAILED = "failed" diff --git a/docs/api-reference/endpoints/pki/subscribers/get-active-cert-bundle.mdx b/docs/api-reference/endpoints/pki/subscribers/get-active-cert-bundle.mdx new file mode 100644 index 000000000..eae668463 --- /dev/null +++ b/docs/api-reference/endpoints/pki/subscribers/get-active-cert-bundle.mdx @@ -0,0 +1,4 @@ +--- +title: "Retrieve active certificate bundle" +openapi: "GET /api/v1/pki/subscribers/{subscriberName}/active-certificate/bundle" +--- diff --git a/docs/mint.json b/docs/mint.json index ea3d3312b..a2a75ca02 100644 --- a/docs/mint.json +++ b/docs/mint.json @@ -1468,7 +1468,8 @@ "api-reference/endpoints/pki/subscribers/update", "api-reference/endpoints/pki/subscribers/delete", "api-reference/endpoints/pki/subscribers/issue-cert", - "api-reference/endpoints/pki/subscribers/sign-cert" + "api-reference/endpoints/pki/subscribers/sign-cert", + "api-reference/endpoints/pki/subscribers/get-active-cert-bundle" ] }, {