diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index 7b8ac6fcc..f0c3cbd5b 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -86,6 +86,7 @@ import { certificateDALFactory } from "@app/services/certificate/certificate-dal import { certificateServiceFactory } from "@app/services/certificate/certificate-service"; import { certificateAuthorityCertDALFactory } from "@app/services/certificate-authority/certificate-authority-cert-dal"; import { certificateAuthorityDALFactory } from "@app/services/certificate-authority/certificate-authority-dal"; +import { certificateAuthorityEstConfigDALFactory } from "@app/services/certificate-authority/certificate-authority-est-config-dal"; import { certificateAuthorityQueueFactory } from "@app/services/certificate-authority/certificate-authority-queue"; import { certificateAuthoritySecretDALFactory } from "@app/services/certificate-authority/certificate-authority-secret-dal"; import { certificateAuthorityServiceFactory } from "@app/services/certificate-authority/certificate-authority-service"; @@ -585,6 +586,7 @@ export const registerRoutes = async ( const certificateAuthorityCertDAL = certificateAuthorityCertDALFactory(db); const certificateAuthoritySecretDAL = certificateAuthoritySecretDALFactory(db); const certificateAuthorityCrlDAL = certificateAuthorityCrlDALFactory(db); + const certificateAuthorityEstConfigDAL = certificateAuthorityEstConfigDALFactory(db); const certificateDAL = certificateDALFactory(db); const certificateBodyDAL = certificateBodyDALFactory(db); @@ -625,7 +627,8 @@ export const registerRoutes = async ( certificateBodyDAL, projectDAL, kmsService, - permissionService + permissionService, + certificateAuthorityEstConfigDAL }); const certificateAuthorityCrlService = certificateAuthorityCrlServiceFactory({ diff --git a/backend/src/server/routes/v1/certificate-authority-router.ts b/backend/src/server/routes/v1/certificate-authority-router.ts index 3148d8451..1306dd657 100644 --- a/backend/src/server/routes/v1/certificate-authority-router.ts +++ b/backend/src/server/routes/v1/certificate-authority-router.ts @@ -2,6 +2,7 @@ import ms from "ms"; import { z } from "zod"; import { CertificateAuthoritiesSchema } from "@app/db/schemas"; +import { CertificateAuthorityEstConfigsSchema } from "@app/db/schemas/certificate-authority-est-configs"; import { EventType } from "@app/ee/services/audit-log/audit-log-types"; import { CERTIFICATE_AUTHORITIES } from "@app/lib/api-docs"; import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; @@ -697,4 +698,128 @@ export const registerCaRouter = async (server: FastifyZodProvider) => { }; } }); + + server.route({ + method: "POST", + url: "/:caId/est-config", + config: { + rateLimit: writeLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + description: "Create CA EST configuration", + params: z.object({ + caId: z.string().trim() + }), + body: z.object({ + caChain: z.string().trim().min(1), + passphrase: z.string().min(1), + isEnabled: z.boolean().default(true) + }), + response: { + 200: z.object({ + caEstConfig: CertificateAuthorityEstConfigsSchema.pick({ + caId: true, + isEnabled: true + }) + }) + } + }, + handler: async (req) => { + const caEstConfig = await server.services.certificateAuthority.createCaEstConfiguration({ + caId: req.params.caId, + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + ...req.body + }); + + return { + caEstConfig + }; + } + }); + + server.route({ + method: "PATCH", + url: "/:caId/est-config", + config: { + rateLimit: writeLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + description: "Update CA EST configuration", + params: z.object({ + caId: z.string().trim() + }), + body: z.object({ + caChain: z.string().trim().min(1).optional(), + passphrase: z.string().min(1).optional(), + isEnabled: z.boolean().optional() + }), + response: { + 200: z.object({ + caEstConfig: CertificateAuthorityEstConfigsSchema.pick({ + caId: true, + isEnabled: true + }) + }) + } + }, + handler: async (req) => { + const caEstConfig = await server.services.certificateAuthority.updateCaEstConfiguration({ + caId: req.params.caId, + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + ...req.body + }); + + return { + caEstConfig + }; + } + }); + + server.route({ + method: "GET", + url: "/:caId/est-config", + config: { + rateLimit: readLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + description: "Get CA EST configuration", + params: z.object({ + caId: z.string().trim() + }), + response: { + 200: z.object({ + caEstConfig: CertificateAuthorityEstConfigsSchema.pick({ + caId: true, + isEnabled: true + }).merge( + z.object({ + caChain: z.string() + }) + ) + }) + } + }, + handler: async (req) => { + const caEstConfig = await server.services.certificateAuthority.getCaEstConfiguration({ + caId: req.params.caId, + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId + }); + + return { + caEstConfig + }; + } + }); }; diff --git a/backend/src/services/certificate-authority/certificate-authority-service.ts b/backend/src/services/certificate-authority/certificate-authority-service.ts index c5d5a053c..89223babb 100644 --- a/backend/src/services/certificate-authority/certificate-authority-service.ts +++ b/backend/src/services/certificate-authority/certificate-authority-service.ts @@ -42,6 +42,7 @@ import { TGetCaCertsDTO, TGetCaCsrDTO, TGetCaDTO, + TGetCaEstConfigurationDTO, TImportCertToCaDTO, TIssueCertFromCaDTO, TRenewCaCertDTO, @@ -1534,6 +1535,62 @@ export const certificateAuthorityServiceFactory = ({ return estConfig; }; + const getCaEstConfiguration = async ({ + caId, + actorId, + actorAuthMethod, + actor, + actorOrgId + }: TGetCaEstConfigurationDTO) => { + const ca = await certificateAuthorityDAL.findById(caId); + if (!ca) { + throw new NotFoundError({ message: "CA not found" }); + } + + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + ca.projectId, + actorAuthMethod, + actorOrgId + ); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Edit, + ProjectPermissionSub.CertificateAuthorities + ); + + const caEstConfig = await certificateAuthorityEstConfigDAL.findOne({ + caId + }); + + if (!caEstConfig) { + throw new NotFoundError({ + message: "CA EST Config not found" + }); + } + + const certificateManagerKmsId = await getProjectKmsCertificateKeyId({ + projectId: ca.projectId, + projectDAL, + kmsService + }); + + const kmsDecryptor = await kmsService.decryptWithKmsKey({ + kmsId: certificateManagerKmsId + }); + + const decryptedCaChain = await kmsDecryptor({ + cipherTextBlob: caEstConfig.encryptedCaChain + }); + + return { + caId, + isEnabled: caEstConfig.isEnabled, + caChain: decryptedCaChain.toString() + }; + }; + return { createCa, getCaById, @@ -1548,6 +1605,7 @@ export const certificateAuthorityServiceFactory = ({ issueCertFromCa, signCertFromCa, createCaEstConfiguration, - updateCaEstConfiguration + updateCaEstConfiguration, + getCaEstConfiguration }; }; diff --git a/backend/src/services/certificate-authority/certificate-authority-types.ts b/backend/src/services/certificate-authority/certificate-authority-types.ts index 48db75557..cbe552b08 100644 --- a/backend/src/services/certificate-authority/certificate-authority-types.ts +++ b/backend/src/services/certificate-authority/certificate-authority-types.ts @@ -180,3 +180,7 @@ export type TUpdateCaEstConfigurationDTO = { passphrase?: string; isEnabled?: boolean; } & Omit; + +export type TGetCaEstConfigurationDTO = { + caId: string; +} & Omit; diff --git a/frontend/src/hooks/api/ca/index.tsx b/frontend/src/hooks/api/ca/index.tsx index 45e675d4b..6d184f6ce 100644 --- a/frontend/src/hooks/api/ca/index.tsx +++ b/frontend/src/hooks/api/ca/index.tsx @@ -1,10 +1,20 @@ -export { CaRenewalType,CaStatus, CaType } from "./enums"; +export { CaRenewalType, CaStatus, CaType } from "./enums"; export { useCreateCa, + useCreateCaEstConfig, useCreateCertificate, useDeleteCa, useImportCaCertificate, useRenewCa, useSignIntermediate, - useUpdateCa} from "./mutations"; -export { useGetCaById, useGetCaCert, useGetCaCerts, useGetCaCrl, useGetCaCsr } from "./queries"; + useUpdateCa, + useUpdateCaEstConfig +} from "./mutations"; +export { + useGetCaById, + useGetCaCert, + useGetCaCerts, + useGetCaCrl, + useGetCaCsr, + useGetCaEstConfig +} from "./queries"; diff --git a/frontend/src/hooks/api/ca/mutations.tsx b/frontend/src/hooks/api/ca/mutations.tsx index 54109adfc..7a4952464 100644 --- a/frontend/src/hooks/api/ca/mutations.tsx +++ b/frontend/src/hooks/api/ca/mutations.tsx @@ -7,6 +7,7 @@ import { caKeys } from "./queries"; import { TCertificateAuthority, TCreateCaDTO, + TCreateCaEstConfigDTO, TCreateCertificateDTO, TCreateCertificateResponse, TDeleteCaDTO, @@ -16,7 +17,9 @@ import { TRenewCaResponse, TSignIntermediateDTO, TSignIntermediateResponse, - TUpdateCaDTO} from "./types"; + TUpdateCaDTO, + TUpdateCaEstConfigDTO +} from "./types"; export const useCreateCa = () => { const queryClient = useQueryClient(); @@ -130,3 +133,29 @@ export const useRenewCa = () => { } }); }; + +export const useCreateCaEstConfig = () => { + const queryClient = useQueryClient(); + return useMutation<{}, {}, TCreateCaEstConfigDTO>({ + mutationFn: async (body) => { + const { data } = await apiRequest.post(`/api/v1/pki/ca/${body.caId}/est-config`, body); + return data; + }, + onSuccess: (_, { caId }) => { + queryClient.invalidateQueries(caKeys.getCaEstConfig(caId)); + } + }); +}; + +export const useUpdateCaEstConfig = () => { + const queryClient = useQueryClient(); + return useMutation<{}, {}, TUpdateCaEstConfigDTO>({ + mutationFn: async (body) => { + const { data } = await apiRequest.patch(`/api/v1/pki/ca/${body.caId}/est-config`, body); + return data; + }, + onSuccess: (_, { caId }) => { + queryClient.invalidateQueries(caKeys.getCaEstConfig(caId)); + } + }); +}; diff --git a/frontend/src/hooks/api/ca/queries.tsx b/frontend/src/hooks/api/ca/queries.tsx index 278c335ca..d4eb8a8ae 100644 --- a/frontend/src/hooks/api/ca/queries.tsx +++ b/frontend/src/hooks/api/ca/queries.tsx @@ -2,14 +2,15 @@ import { useQuery } from "@tanstack/react-query"; import { apiRequest } from "@app/config/request"; -import { TCertificateAuthority } from "./types"; +import { TCaEstConfig, TCertificateAuthority } from "./types"; export const caKeys = { getCaById: (caId: string) => [{ caId }, "ca"], getCaCerts: (caId: string) => [{ caId }, "ca-cert"], getCaCert: (caId: string) => [{ caId }, "ca-cert"], getCaCsr: (caId: string) => [{ caId }, "ca-csr"], - getCaCrl: (caId: string) => [{ caId }, "ca-crl"] + getCaCrl: (caId: string) => [{ caId }, "ca-crl"], + getCaEstConfig: (caId: string) => [{ caId }, "ca-est-config"] }; export const useGetCaById = (caId: string) => { @@ -87,3 +88,19 @@ export const useGetCaCrl = (caId: string) => { enabled: Boolean(caId) }); }; + +export const useGetCaEstConfig = (caId: string) => { + return useQuery({ + queryKey: caKeys.getCaEstConfig(caId), + queryFn: async () => { + const { + data: { caEstConfig } + } = await apiRequest.get<{ + caEstConfig: TCaEstConfig; + }>(`/api/v1/pki/ca/${caId}/est-config`); + + return caEstConfig; + }, + enabled: Boolean(caId) + }); +}; diff --git a/frontend/src/hooks/api/ca/types.ts b/frontend/src/hooks/api/ca/types.ts index bba7aa699..741eae2ef 100644 --- a/frontend/src/hooks/api/ca/types.ts +++ b/frontend/src/hooks/api/ca/types.ts @@ -1,5 +1,5 @@ import { CertKeyAlgorithm } from "../certificates/enums"; -import { CaRenewalType,CaStatus, CaType } from "./enums"; +import { CaRenewalType, CaStatus, CaType } from "./enums"; export type TCertificateAuthority = { id: string; @@ -107,3 +107,23 @@ export type TRenewCaResponse = { certificateChain: string; serialNumber: string; }; + +export type TCaEstConfig = { + caId: string; + caChain: string; + isEnabled: false; +}; + +export type TCreateCaEstConfigDTO = { + caId: string; + caChain: string; + passphrase: string; + isEnabled: boolean; +}; + +export type TUpdateCaEstConfigDTO = { + caId: string; + caChain?: string; + passphrase?: string; + isEnabled?: boolean; +}; diff --git a/frontend/src/views/Project/CaPage/components/CaDetailsSection.tsx b/frontend/src/views/Project/CaPage/components/CaDetailsSection.tsx index 5b0a2fb6a..eee00d017 100644 --- a/frontend/src/views/Project/CaPage/components/CaDetailsSection.tsx +++ b/frontend/src/views/Project/CaPage/components/CaDetailsSection.tsx @@ -125,7 +125,9 @@ export const CaDetailsSection = ({ caId, handlePopUpOpen }: Props) => { colorSchema="secondary" type="submit" onClick={() => { - handlePopUpOpen("enrollmentOptions"); + handlePopUpOpen("enrollmentOptions", { + caId + }); }} > Enrollment Options diff --git a/frontend/src/views/Project/CaPage/components/CaEnrollmentModal.tsx b/frontend/src/views/Project/CaPage/components/CaEnrollmentModal.tsx index 468a05f90..081557c24 100644 --- a/frontend/src/views/Project/CaPage/components/CaEnrollmentModal.tsx +++ b/frontend/src/views/Project/CaPage/components/CaEnrollmentModal.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"; @@ -14,6 +15,7 @@ import { Switch, TextArea } from "@app/components/v2"; +import { useCreateCaEstConfig, useGetCaEstConfig, useUpdateCaEstConfig } from "@app/hooks/api"; import { UsePopUpState } from "@app/hooks/usePopUp"; enum EnrollmentMethod { @@ -28,32 +30,73 @@ type Props = { ) => void; }; -const schema = z - .object({ - method: z.nativeEnum(EnrollmentMethod), - caChain: z.string(), - passphrase: z.string(), - isEnabled: z.boolean() - }) - .required(); +const schema = z.object({ + method: z.nativeEnum(EnrollmentMethod), + caChain: z.string(), + passphrase: z.string().optional(), + isEnabled: z.boolean() +}); export type FormData = z.infer; export const CaEnrollmentModal = ({ popUp, handlePopUpToggle }: Props) => { + const popUpData = popUp?.enrollmentOptions?.data as { + caId: string; + }; + const caId = popUpData?.caId; + + const { data } = useGetCaEstConfig(caId); + const { control, handleSubmit, reset, + setError, formState: { isSubmitting } } = useForm({ - resolver: zodResolver(schema), - defaultValues: { - isEnabled: false - } + resolver: zodResolver(schema) }); + const { mutateAsync: createCaEstConfig } = useCreateCaEstConfig(); + const { mutateAsync: updateCaEstConfig } = useUpdateCaEstConfig(); + + useEffect(() => { + if (data) { + reset({ + caChain: data.caChain, + isEnabled: data.isEnabled + }); + } else { + reset({ + caChain: "", + isEnabled: false + }); + } + }, [data]); + const onFormSubmit = async ({ caChain, passphrase, isEnabled }: FormData) => { try { + if (data) { + await updateCaEstConfig({ + caId, + caChain, + passphrase, + isEnabled + }); + } else { + if (!passphrase) { + setError("passphrase", { message: "Passphrase is required to setup EST enrollment." }); + return; + } + + await createCaEstConfig({ + caId, + caChain, + passphrase, + isEnabled + }); + } + handlePopUpToggle("enrollmentOptions", false); createNotification({ @@ -102,7 +145,6 @@ export const CaEnrollmentModal = ({ popUp, handlePopUpToggle }: Props) => { /> ( { /> ( - + )}