feat: initial integration of cert template management

This commit is contained in:
Sheen Capadngan
2024-08-15 21:06:37 +08:00
parent 05efd95472
commit 28a2a6c41a
14 changed files with 595 additions and 3 deletions

View File

@@ -476,7 +476,11 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => {
CertificateTemplatesSchema.pick({
id: true,
name: true
})
}).merge(
z.object({
caName: z.string()
})
)
)
})
}

View File

@@ -16,7 +16,8 @@ export const certificateTemplateDALFactory = (db: TDbClient) => {
`${TableName.CertificateTemplate}.caId`
)
.where(`${TableName.CertificateAuthority}.projectId`, "=", projectId)
.select(selectAllTableCols(TableName.CertificateTemplate));
.select(selectAllTableCols(TableName.CertificateTemplate))
.select(db.ref("friendlyName").as("caName").withSchema(TableName.CertificateAuthority));
return certTemplates;
};

View File

@@ -28,6 +28,7 @@ export enum ProjectPermissionSub {
Identity = "identity",
CertificateAuthorities = "certificate-authorities",
Certificates = "certificates",
CertificateTemplates = "certificate-templates",
PkiAlerts = "pki-alerts",
PkiCollections = "pki-collections",
Kms = "kms"
@@ -59,6 +60,7 @@ export type ProjectPermissionSet =
| [ProjectPermissionActions, ProjectPermissionSub.SecretRotation]
| [ProjectPermissionActions, ProjectPermissionSub.CertificateAuthorities]
| [ProjectPermissionActions, ProjectPermissionSub.Certificates]
| [ProjectPermissionActions, ProjectPermissionSub.CertificateTemplates]
| [ProjectPermissionActions, ProjectPermissionSub.PkiAlerts]
| [ProjectPermissionActions, ProjectPermissionSub.PkiCollections]
| [ProjectPermissionActions.Delete, ProjectPermissionSub.Workspace]

View File

@@ -0,0 +1,2 @@
export { useCreateCertTemplate, useDeleteCertTemplate, useUpdateCertTemplate } from "./mutations";
export { useGetCertTemplate } from "./queries";

View File

@@ -0,0 +1,63 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { apiRequest } from "@app/config/request";
import { workspaceKeys } from "../workspace/queries";
import { certTemplateKeys } from "./queries";
import {
TCertificateTemplate,
TCreateCertificateTemplateDTO,
TDeleteCertificateTemplateDTO,
TUpdateCertificateTemplateDTO
} from "./types";
export const useCreateCertTemplate = () => {
const queryClient = useQueryClient();
return useMutation<TCertificateTemplate, {}, TCreateCertificateTemplateDTO>({
mutationFn: async (data) => {
const {
data: { certificateTemplate }
} = await apiRequest.post<{ certificateTemplate: TCertificateTemplate }>(
"/api/v1/pki/certificate-templates",
data
);
return certificateTemplate;
},
onSuccess: (_, { projectId }) => {
queryClient.invalidateQueries(workspaceKeys.getWorkspaceCertificateTemplates(projectId));
}
});
};
export const useUpdateCertTemplate = () => {
const queryClient = useQueryClient();
return useMutation<TCertificateTemplate, {}, TUpdateCertificateTemplateDTO>({
mutationFn: async (data) => {
const {
data: { certificateTemplate }
} = await apiRequest.patch<{ certificateTemplate: TCertificateTemplate }>(
`/api/v1/pki/certificate-templates/${data.id}`,
data
);
return certificateTemplate;
},
onSuccess: (_, { projectId, id }) => {
queryClient.invalidateQueries(workspaceKeys.getWorkspaceCertificateTemplates(projectId));
queryClient.invalidateQueries(certTemplateKeys.getCertTemplateById(id));
}
});
};
export const useDeleteCertTemplate = () => {
const queryClient = useQueryClient();
return useMutation<void, {}, TDeleteCertificateTemplateDTO>({
mutationFn: async (data) => {
return apiRequest.delete(`/api/v1/pki/certificate-templates/${data.id}`);
},
onSuccess: (_, { projectId, id }) => {
queryClient.invalidateQueries(workspaceKeys.getWorkspaceCertificateTemplates(projectId));
queryClient.invalidateQueries(certTemplateKeys.getCertTemplateById(id));
}
});
};

View File

@@ -0,0 +1,24 @@
import { useQuery } from "@tanstack/react-query";
import { apiRequest } from "@app/config/request";
import { TCertificateTemplate } from "./types";
export const certTemplateKeys = {
getCertTemplateById: (id: string) => [{ id }, "cert-template"]
};
export const useGetCertTemplate = (id: string) => {
return useQuery({
queryKey: certTemplateKeys.getCertTemplateById(id),
queryFn: async () => {
const {
data: { certificateTemplate }
} = await apiRequest.get<{ certificateTemplate: TCertificateTemplate }>(
`/api/v1/pki/certificate-templates/${id}`
);
return certificateTemplate;
},
enabled: Boolean(id)
});
};

View File

@@ -0,0 +1,35 @@
export type TCertificateTemplateListEntry = {
id: string;
name: string;
caName: string;
};
export type TCertificateTemplate = {
id: string;
caId: string;
name: string;
commonName: string;
ttl: string;
};
export type TCreateCertificateTemplateDTO = {
caId: string;
name: string;
commonName: string;
ttl: string;
projectId: string;
};
export type TUpdateCertificateTemplateDTO = {
id: string;
caId?: string;
name?: string;
commonName?: string;
ttl?: string;
projectId: string;
};
export type TDeleteCertificateTemplateDTO = {
id: string;
projectId: string;
};

View File

@@ -7,6 +7,7 @@ export * from "./auth";
export * from "./bots";
export * from "./ca";
export * from "./certificates";
export * from "./certificateTemplates";
export * from "./dynamicSecret";
export * from "./dynamicSecretLease";
export * from "./groups";

View File

@@ -25,6 +25,7 @@ export {
useGetWorkspaceUsers,
useListWorkspaceCas,
useListWorkspaceCertificates,
useListWorkspaceCertificateTemplates,
useListWorkspaceGroups,
useListWorkspacePkiAlerts,
useListWorkspacePkiCollections,

View File

@@ -5,6 +5,7 @@ import { apiRequest } from "@app/config/request";
import { CaStatus } from "../ca/enums";
import { TCertificateAuthority } from "../ca/types";
import { TCertificate } from "../certificates/types";
import { TCertificateTemplateListEntry } from "../certificateTemplates/types";
import { TGroupMembership } from "../groups/types";
import { identitiesKeys } from "../identities/queries";
import { IdentityMembership } from "../identities/types";
@@ -68,7 +69,9 @@ export const workspaceKeys = {
getWorkspacePkiAlerts: (workspaceId: string) =>
[{ workspaceId }, "workspace-pki-alerts"] as const,
getWorkspacePkiCollections: (workspaceId: string) =>
[{ workspaceId }, "workspace-pki-collections"] as const
[{ workspaceId }, "workspace-pki-collections"] as const,
getWorkspaceCertificateTemplates: (workspaceId: string) =>
[{ workspaceId }, "workspace-certificate-templates"] as const
};
const fetchWorkspaceById = async (workspaceId: string) => {
@@ -639,3 +642,19 @@ export const useListWorkspacePkiCollections = ({ workspaceId }: { workspaceId: s
enabled: Boolean(workspaceId)
});
};
export const useListWorkspaceCertificateTemplates = ({ workspaceId }: { workspaceId: string }) => {
return useQuery({
queryKey: workspaceKeys.getWorkspaceCertificateTemplates(workspaceId),
queryFn: async () => {
const {
data: { certificateTemplates }
} = await apiRequest.get<{ certificateTemplates: TCertificateTemplateListEntry[] }>(
`/api/v2/workspace/${workspaceId}/certificate-templates`
);
return { certificateTemplates };
},
enabled: Boolean(workspaceId)
});
};

View File

@@ -1,5 +1,6 @@
import { motion } from "framer-motion";
import { CertificateTemplatesSection } from "./components/CertificateTemplatesSection";
import { CertificatesSection } from "./components";
export const CertificatesTab = () => {
@@ -11,6 +12,7 @@ export const CertificatesTab = () => {
animate={{ opacity: 1, translateX: 0 }}
exit={{ opacity: 0, translateX: 30 }}
>
<CertificateTemplatesSection />
<CertificatesSection />
</motion.div>
);

View File

@@ -0,0 +1,234 @@
import { useEffect } 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 {
CaStatus,
useCreateCertTemplate,
useGetCertTemplate,
useListWorkspaceCas,
useUpdateCertTemplate
} from "@app/hooks/api";
import { caTypeToNameMap } from "@app/hooks/api/ca/constants";
import { UsePopUpState } from "@app/hooks/usePopUp";
const schema = z.object({
caId: z.string(),
name: z.string().min(1),
commonName: z.string().trim().min(1),
ttl: z.string().trim().min(1)
});
export type FormData = z.infer<typeof schema>;
type Props = {
popUp: UsePopUpState<["certificateTemplate"]>;
handlePopUpToggle: (
popUpName: keyof UsePopUpState<["certificateTemplate"]>,
state?: boolean
) => void;
};
export const CertificateTemplateModal = ({ popUp, handlePopUpToggle }: Props) => {
const { currentWorkspace } = useWorkspace();
const { data: certTemplate } = useGetCertTemplate(
(popUp?.certificateTemplate?.data as { id: string })?.id || ""
);
const { data: cas } = useListWorkspaceCas({
projectSlug: currentWorkspace?.slug ?? "",
status: CaStatus.ACTIVE
});
const { mutateAsync: createCertTemplate } = useCreateCertTemplate();
const { mutateAsync: updateCertTemplate } = useUpdateCertTemplate();
const {
control,
handleSubmit,
reset,
formState: { isSubmitting }
} = useForm<FormData>({
resolver: zodResolver(schema)
});
useEffect(() => {
if (certTemplate) {
reset({
caId: certTemplate.caId,
name: certTemplate.name,
commonName: certTemplate.commonName,
ttl: certTemplate.ttl
});
} else {
reset({
caId: "",
name: "",
commonName: "",
ttl: ""
});
}
}, [certTemplate]);
const onFormSubmit = async ({ caId, name, commonName, ttl }: FormData) => {
if (!currentWorkspace?.id) {
return;
}
try {
if (certTemplate) {
await updateCertTemplate({
id: certTemplate.id,
projectId: currentWorkspace.id,
caId,
name,
commonName,
ttl
});
createNotification({
text: "Successfully updated certificate template",
type: "success"
});
} else {
await createCertTemplate({
projectId: currentWorkspace.id,
caId,
name,
commonName,
ttl
});
createNotification({
text: "Successfully created certificate template",
type: "success"
});
}
reset();
handlePopUpToggle("certificateTemplate", false);
} catch (err) {
console.error(err);
createNotification({
text: "Failed to save changes",
type: "error"
});
}
};
return (
<Modal
isOpen={popUp?.certificateTemplate?.isOpen}
onOpenChange={(isOpen) => {
handlePopUpToggle("certificateTemplate", isOpen);
reset();
}}
>
<ModalContent title={certTemplate ? "Certificate Template" : "Create Certificate Template"}>
<form onSubmit={handleSubmit(onFormSubmit)}>
<Controller
control={control}
defaultValue=""
name="name"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Template Name"
isError={Boolean(error)}
errorText={error?.message}
isRequired
>
<Input {...field} placeholder="My Certificate Template" />
</FormControl>
)}
/>
<Controller
control={control}
name="caId"
defaultValue=""
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
<FormControl
label="Issuing CA"
errorText={error?.message}
isError={Boolean(error)}
className="mt-4"
isRequired
>
<Select
defaultValue={field.value}
{...field}
onValueChange={(e) => onChange(e)}
className="w-full"
>
{(cas || []).map(({ id, type, dn }) => (
<SelectItem value={id} key={`ca-${id}`}>
{`${caTypeToNameMap[type]}: ${dn}`}
</SelectItem>
))}
</Select>
</FormControl>
)}
/>
<Controller
control={control}
defaultValue=""
name="commonName"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Common Name (CN)"
isError={Boolean(error)}
errorText={error?.message}
isRequired
>
<Input {...field} placeholder="service.acme.com" />
</FormControl>
)}
/>
<Controller
control={control}
name="ttl"
render={({ field, fieldState: { error } }) => (
<FormControl
label="TTL"
isError={Boolean(error)}
errorText={error?.message}
isRequired
>
<Input {...field} placeholder="2 days, 1d, 2h, 1y, ..." />
</FormControl>
)}
/>
<div className="flex items-center">
<Button
className="mr-4"
size="sm"
type="submit"
isLoading={isSubmitting}
isDisabled={isSubmitting}
>
Save
</Button>
<Button
colorSchema="secondary"
variant="plain"
onClick={() => handlePopUpToggle("certificateTemplate", false)}
>
Cancel
</Button>
</div>
</form>
</ModalContent>
</Modal>
);
};

View File

@@ -0,0 +1,87 @@
import { faPlus } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
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 { usePopUp } from "@app/hooks";
import { useDeleteCertTemplate } from "@app/hooks/api";
import { CertificateTemplateModal } from "./CertificateTemplateModal";
import { CertificateTemplatesTable } from "./CertificateTemplatesTable";
export const CertificateTemplatesSection = () => {
const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([
"certificateTemplate",
"deleteCertificateTemplate"
] as const);
const { currentWorkspace } = useWorkspace();
const { mutateAsync: deleteCertTemplate } = useDeleteCertTemplate();
const onRemoveCertificateTemplateSubmit = async (id: string) => {
if (!currentWorkspace?.id) {
return;
}
try {
await deleteCertTemplate({
id,
projectId: currentWorkspace.id
});
await createNotification({
text: "Successfully deleted certificate template",
type: "success"
});
handlePopUpClose("deleteCertificateTemplate");
} catch (err) {
console.error(err);
createNotification({
text: "Failed to delete certificate template",
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">
<p className="text-xl font-semibold text-mineshaft-100">Certificate Templates</p>
<ProjectPermissionCan
I={ProjectPermissionActions.Create}
a={ProjectPermissionSub.Certificates}
>
{(isAllowed) => (
<Button
colorSchema="primary"
type="submit"
leftIcon={<FontAwesomeIcon icon={faPlus} />}
onClick={() => handlePopUpOpen("certificateTemplate")}
isDisabled={!isAllowed}
>
Create Certificate Template
</Button>
)}
</ProjectPermissionCan>
</div>
<CertificateTemplatesTable handlePopUpOpen={handlePopUpOpen} />
<CertificateTemplateModal popUp={popUp} handlePopUpToggle={handlePopUpToggle} />
<DeleteActionModal
isOpen={popUp.deleteCertificateTemplate.isOpen}
title={`Are you sure want to delete the certificate template ${
(popUp?.deleteCertificateTemplate?.data as { name: string })?.name || ""
} from the project?`}
onChange={(isOpen) => handlePopUpToggle("deleteCertificateTemplate", isOpen)}
deleteKey="confirm"
onDeleteApproved={() =>
onRemoveCertificateTemplateSubmit(
(popUp?.deleteCertificateTemplate?.data as { id: string })?.id
)
}
/>
</div>
);
};

View File

@@ -0,0 +1,117 @@
import { faEllipsis, faGear, faTrash } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { twMerge } from "tailwind-merge";
import { ProjectPermissionCan } from "@app/components/permissions";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
EmptyState,
Table,
TableContainer,
TableSkeleton,
TBody,
Td,
Th,
THead,
Tooltip,
Tr
} from "@app/components/v2";
import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context";
import { useListWorkspaceCertificateTemplates } from "@app/hooks/api";
import { UsePopUpState } from "@app/hooks/usePopUp";
type Props = {
handlePopUpOpen: (
popUpName: keyof UsePopUpState<["certificateTemplate", "deleteCertificateTemplate"]>,
data?: {
id?: string;
name?: string;
}
) => void;
};
export const CertificateTemplatesTable = ({ handlePopUpOpen }: Props) => {
const { currentWorkspace } = useWorkspace();
const { data, isLoading } = useListWorkspaceCertificateTemplates({
workspaceId: currentWorkspace?.id ?? ""
});
return (
<div>
<TableContainer>
<Table>
<THead>
<Tr>
<Th>Name</Th>
<Th>Certificate Authority</Th>
<Th />
</Tr>
</THead>
<TBody>
{isLoading && <TableSkeleton columns={2} innerKey="project-cas" />}
{!isLoading &&
data?.certificateTemplates.map((certificateTemplate) => {
return (
<Tr className="h-10" key={`certificate-${certificateTemplate.id}`}>
<Td>{certificateTemplate.name}</Td>
<Td>{certificateTemplate.caName}</Td>
<Td className="flex justify-end">
<DropdownMenu>
<DropdownMenuTrigger asChild className="rounded-lg">
<div className="hover:text-primary-400 data-[state=open]:text-primary-400">
<Tooltip content="More options">
<FontAwesomeIcon size="lg" icon={faEllipsis} />
</Tooltip>
</div>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="p-1">
<DropdownMenuItem
onClick={() =>
handlePopUpOpen("certificateTemplate", {
id: certificateTemplate.id
})
}
icon={<FontAwesomeIcon icon={faGear} />}
>
Manage
</DropdownMenuItem>
<ProjectPermissionCan
I={ProjectPermissionActions.Delete}
a={ProjectPermissionSub.CertificateTemplates}
>
{(isAllowed) => (
<DropdownMenuItem
className={twMerge(
!isAllowed && "pointer-events-none cursor-not-allowed opacity-50"
)}
disabled={!isAllowed}
icon={<FontAwesomeIcon icon={faTrash} />}
onClick={() =>
handlePopUpOpen("deleteCertificateTemplate", {
id: certificateTemplate.id,
name: certificateTemplate.name
})
}
>
Delete Template
</DropdownMenuItem>
)}
</ProjectPermissionCan>
</DropdownMenuContent>
</DropdownMenu>
</Td>
</Tr>
);
})}
</TBody>
</Table>
{!isLoading && !data?.certificateTemplates?.length && (
<EmptyState title="No certificate templates have been created" icon={faGear} />
)}
</TableContainer>
</div>
);
};