Add preliminary dynamically generated CRLs on fetching CA CRL

This commit is contained in:
Tuan Dang
2024-05-30 15:45:34 -07:00
parent 45fdd4ebc2
commit 2937a46943
25 changed files with 560 additions and 84 deletions
@@ -0,0 +1,92 @@
import { useEffect } from "react";
import { faCheck, faCopy, faDownload } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { IconButton,Modal, ModalContent } from "@app/components/v2";
import { useToggle } from "@app/hooks";
import { useGetCaCrl } from "@app/hooks/api";
import { UsePopUpState } from "@app/hooks/usePopUp";
type Props = {
popUp: UsePopUpState<["caCrl"]>;
handlePopUpToggle: (popUpName: keyof UsePopUpState<["caCrl"]>, state?: boolean) => void;
};
export const CaCrlModal = ({ popUp, handlePopUpToggle }: Props) => {
const [isCrlCopied, setIsCrlCopied] = useToggle(false);
const { data: crl } = useGetCaCrl((popUp?.caCrl?.data as { caId: string })?.caId || "");
useEffect(() => {
let timer: NodeJS.Timeout;
if (isCrlCopied) {
timer = setTimeout(() => setIsCrlCopied.off(), 2000);
}
return () => clearTimeout(timer);
}, [isCrlCopied]);
const downloadTxtFile = (filename: string, content: string) => {
const blob = new Blob([content], { type: "text/plain" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
};
return (
<Modal
isOpen={popUp?.caCrl?.isOpen}
onOpenChange={(isOpen) => {
handlePopUpToggle("caCrl", isOpen);
}}
>
<ModalContent title="CA Certificate Revocation List (CRL)">
<div>
{crl && (
<>
<div className="mb-4 flex items-center justify-between">
<h2>CA CRL</h2>
<div className="flex">
<IconButton
ariaLabel="copy icon"
colorSchema="secondary"
className="group relative"
onClick={() => {
navigator.clipboard.writeText(crl);
setIsCrlCopied.on();
}}
>
<FontAwesomeIcon icon={isCrlCopied ? faCheck : faCopy} />
<span className="absolute -left-8 -top-20 hidden w-28 translate-y-full rounded-md bg-bunker-800 py-2 pl-3 text-center text-sm text-gray-400 group-hover:flex group-hover:animate-fadeIn">
Copy
</span>
</IconButton>
<IconButton
ariaLabel="copy icon"
colorSchema="secondary"
className="group relative ml-2"
onClick={() => {
downloadTxtFile("crl.pem", crl);
}}
>
<FontAwesomeIcon icon={faDownload} />
<span className="absolute -left-8 -top-20 hidden w-28 translate-y-full rounded-md bg-bunker-800 py-2 pl-3 text-center text-sm text-gray-400 group-hover:flex group-hover:animate-fadeIn">
Download
</span>
</IconButton>
</div>
</div>
<div className="mb-8 flex items-center justify-between rounded-md bg-white/[0.07] p-2 text-base text-gray-400">
<p className="mr-4 whitespace-pre-wrap break-all">{crl}</p>
</div>
</>
)}
</div>
</ModalContent>
</Modal>
);
};
@@ -5,10 +5,11 @@ import { createNotification } from "@app/components/notifications";
import { ProjectPermissionCan } from "@app/components/permissions";
import { Button, DeleteActionModal } from "@app/components/v2";
import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context";
import { CaStatus,useDeleteCa, useUpdateCa } from "@app/hooks/api";
import { CaStatus, useDeleteCa, useUpdateCa } from "@app/hooks/api";
import { usePopUp } from "@app/hooks/usePopUp";
import { CaCertModal } from "./CaCertModal";
import { CaCrlModal } from "./CaCrlModal";
import { CaInstallCertModal } from "./CaInstallCertModal";
import { CaModal } from "./CaModal";
import { CaTable } from "./CaTable";
@@ -23,7 +24,8 @@ export const CaSection = () => {
"caCert",
"installCaCert",
"deleteCa",
"caStatus" // enable / disable
"caStatus", // enable / disable
"caCrl" // enable / disable
] as const);
const onRemoveCaSubmit = async (caId: string) => {
@@ -92,6 +94,7 @@ export const CaSection = () => {
<CaModal popUp={popUp} handlePopUpToggle={handlePopUpToggle} />
<CaInstallCertModal popUp={popUp} handlePopUpToggle={handlePopUpToggle} />
<CaCertModal popUp={popUp} handlePopUpToggle={handlePopUpToggle} />
<CaCrlModal popUp={popUp} handlePopUpToggle={handlePopUpToggle} />
<CaTable handlePopUpOpen={handlePopUpOpen} />
<DeleteActionModal
isOpen={popUp.deleteCa.isOpen}
@@ -3,6 +3,7 @@ import {
faCertificate,
faEllipsis,
faEye,
faFile,
faTrash
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
@@ -24,15 +25,18 @@ import {
Th,
THead,
Tooltip,
Tr} from "@app/components/v2";
Tr
} from "@app/components/v2";
import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context";
import { CaStatus, useListWorkspaceCas } from "@app/hooks/api";
import { caStatusToNameMap,caTypeToNameMap } from "@app/hooks/api/ca/constants";
import { caStatusToNameMap, caTypeToNameMap } from "@app/hooks/api/ca/constants";
import { UsePopUpState } from "@app/hooks/usePopUp";
type Props = {
handlePopUpOpen: (
popUpName: keyof UsePopUpState<["installCaCert", "caCert", "ca", "deleteCa", "caStatus"]>,
popUpName: keyof UsePopUpState<
["installCaCert", "caCert", "ca", "deleteCa", "caStatus", "caCrl"]
>,
data?: {
caId?: string;
dn?: string;
@@ -129,6 +133,27 @@ export const CaTable = ({ handlePopUpOpen }: Props) => {
)}
</ProjectPermissionCan>
)}
<ProjectPermissionCan
I={ProjectPermissionActions.Read}
a={ProjectPermissionSub.CertificateAuthorities}
>
{(isAllowed) => (
<DropdownMenuItem
className={twMerge(
!isAllowed && "pointer-events-none cursor-not-allowed opacity-50"
)}
onClick={async () =>
handlePopUpOpen("caCrl", {
caId: ca.id
})
}
disabled={!isAllowed}
icon={<FontAwesomeIcon icon={faFile} />}
>
View CRL
</DropdownMenuItem>
)}
</ProjectPermissionCan>
<ProjectPermissionCan
I={ProjectPermissionActions.Read}
a={ProjectPermissionSub.CertificateAuthorities}
@@ -98,7 +98,7 @@ export const CertificateContent = ({
colorSchema="secondary"
className="group relative ml-2"
onClick={() => {
downloadTxtFile("certificate.txt", certificate);
downloadTxtFile("certificate.pem", certificate);
}}
>
<FontAwesomeIcon icon={faDownload} />
@@ -135,7 +135,7 @@ export const CertificateContent = ({
colorSchema="secondary"
className="group relative ml-2"
onClick={() => {
downloadTxtFile("certificate_chain.txt", certificateChain);
downloadTxtFile("chain.pem", certificateChain);
}}
>
<FontAwesomeIcon icon={faDownload} />
@@ -0,0 +1,131 @@
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, Modal, ModalContent, Select, SelectItem } from "@app/components/v2";
import { useWorkspace } from "@app/context";
import { useRevokeCert } from "@app/hooks/api";
import { crlReasons } from "@app/hooks/api/certificates/constants";
import { CrlReason } from "@app/hooks/api/certificates/enums";
import { UsePopUpState } from "@app/hooks/usePopUp";
const schema = z.object({
revocationReason: z.enum([
CrlReason.UNSPECIFIED,
CrlReason.KEY_COMPROMISE,
CrlReason.CA_COMPROMISE,
CrlReason.AFFILIATION_CHANGED,
CrlReason.SUPERSEDED,
CrlReason.CESSATION_OF_OPERATION,
CrlReason.CERTIFICATE_HOLD,
CrlReason.PRIVILEGE_WITHDRAWN,
CrlReason.A_A_COMPROMISE
])
});
export type FormData = z.infer<typeof schema>;
type Props = {
popUp: UsePopUpState<["revokeCertificate"]>;
handlePopUpToggle: (
popUpName: keyof UsePopUpState<["revokeCertificate"]>,
state?: boolean
) => void;
};
export const CertificateRevocationModal = ({ popUp, handlePopUpToggle }: Props) => {
const { currentWorkspace } = useWorkspace();
const { mutateAsync: revokeCertificate } = useRevokeCert();
const {
control,
handleSubmit,
reset,
formState: { isSubmitting }
} = useForm<FormData>({
resolver: zodResolver(schema)
});
const onFormSubmit = async ({ revocationReason }: FormData) => {
try {
if (!currentWorkspace?.slug) return;
const {serialNumber} = popUp.revokeCertificate.data as { serialNumber: string };
await revokeCertificate({
projectSlug: currentWorkspace.slug,
serialNumber,
revocationReason
});
reset();
handlePopUpToggle("revokeCertificate", false);
createNotification({
text: "Successfully revoked certificate",
type: "success"
});
} catch (err) {
console.error(err);
createNotification({
text: "Failed to revoke certificate",
type: "error"
});
}
};
return (
<Modal
isOpen={popUp?.revokeCertificate?.isOpen}
onOpenChange={(isOpen) => {
handlePopUpToggle("revokeCertificate", isOpen);
reset();
}}
>
<ModalContent title="Revoke Certificate">
<form onSubmit={handleSubmit(onFormSubmit)}>
<Controller
control={control}
name="revocationReason"
defaultValue={CrlReason.UNSPECIFIED}
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
<FormControl
label="Revocation Reason"
errorText={error?.message}
isError={Boolean(error)}
>
<Select
defaultValue={field.value}
{...field}
onValueChange={(e) => onChange(e)}
className="w-full"
>
{crlReasons.map(({ label, value }) => (
<SelectItem value={String(value || "")} key={label}>
{label}
</SelectItem>
))}
</Select>
</FormControl>
)}
/>
<div className="flex items-center">
<Button
className="mr-4"
size="sm"
type="submit"
isLoading={isSubmitting}
isDisabled={isSubmitting}
>
Revoke
</Button>
<Button colorSchema="secondary" variant="plain">
Cancel
</Button>
</div>
</form>
</ModalContent>
</Modal>
);
};
@@ -5,17 +5,17 @@ import { createNotification } from "@app/components/notifications";
import { ProjectPermissionCan } from "@app/components/permissions";
import { Button, DeleteActionModal } from "@app/components/v2";
import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context";
import { useDeleteCert, useRevokeCert } from "@app/hooks/api";
import { useDeleteCert } from "@app/hooks/api";
import { usePopUp } from "@app/hooks/usePopUp";
import { CertificateCertModal } from "./CertificateCertModal";
import { CertificateModal } from "./CertificateModal";
import { CertificateRevocationModal } from "./CertificateRevocationModal";
import { CertificatesTable } from "./CertificatesTable";
export const CertificatesSection = () => {
const { currentWorkspace } = useWorkspace();
const { mutateAsync: deleteCert } = useDeleteCert();
const { mutateAsync: revokeCert } = useRevokeCert();
const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([
"certificate",
@@ -45,27 +45,6 @@ export const CertificatesSection = () => {
}
};
const onRevokeCertificateSubmit = async (serialNumber: string) => {
try {
if (!currentWorkspace?.slug) return;
await revokeCert({ serialNumber, projectSlug: currentWorkspace.slug });
await createNotification({
text: "Successfully revoked certificate",
type: "success"
});
handlePopUpClose("revokeCertificate");
} catch (err) {
console.error(err);
createNotification({
text: "Failed to revoke certificate",
type: "error"
});
}
};
return (
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="mb-4 flex justify-between">
@@ -90,6 +69,7 @@ export const CertificatesSection = () => {
<CertificatesTable handlePopUpOpen={handlePopUpOpen} />
<CertificateModal popUp={popUp} handlePopUpToggle={handlePopUpToggle} />
<CertificateCertModal popUp={popUp} handlePopUpToggle={handlePopUpToggle} />
<CertificateRevocationModal popUp={popUp} handlePopUpToggle={handlePopUpToggle} />
<DeleteActionModal
isOpen={popUp.deleteCertificate.isOpen}
title={`Are you sure want to remove the certificate ${
@@ -103,20 +83,6 @@ export const CertificatesSection = () => {
)
}
/>
<DeleteActionModal
isOpen={popUp.revokeCertificate.isOpen}
title={`Are you sure want to revoke the certificate ${
(popUp?.revokeCertificate?.data as { commonName: string })?.commonName || ""
} from the project?`}
subTitle="This action is irreversible and will add the certificate to the CRL"
onChange={(isOpen) => handlePopUpToggle("revokeCertificate", isOpen)}
deleteKey="confirm"
onDeleteApproved={() =>
onRevokeCertificateSubmit(
(popUp?.revokeCertificate?.data as { serialNumber: string }).serialNumber
)
}
/>
</div>
);
};