PKI revamp: general improvements

This commit is contained in:
Carlos Monastyrski
2025-10-17 15:47:41 -03:00
parent 4f23e6dc53
commit e195afba11
34 changed files with 601 additions and 579 deletions
@@ -129,8 +129,7 @@ export enum ProjectPermissionCertificateProfileActions {
Create = "create",
Edit = "edit",
Delete = "delete",
IssueCert = "issue-cert",
ListCerts = "list-certs"
IssueCert = "issue-cert"
}
export enum ProjectPermissionSecretRotationActions {
@@ -226,7 +225,6 @@ export type ConditionalProjectPermissionSubject =
| ProjectPermissionSub.SshHosts
| ProjectPermissionSub.PkiSubscribers
| ProjectPermissionSub.CertificateTemplates
| ProjectPermissionSub.CertificateProfiles
| ProjectPermissionSub.SecretFolders
| ProjectPermissionSub.SecretImports
| ProjectPermissionSub.SecretRotation
+4
View File
@@ -166,6 +166,10 @@ export const useCreateCertificateV3 = () => {
queryClient.invalidateQueries({
queryKey: projectKeys.forProjectCertificates(projectSlug)
});
queryClient.invalidateQueries({
queryKey: ["certificate-profiles"]
});
}
});
};
+6 -2
View File
@@ -176,7 +176,7 @@ export type TCreateCertificateV3DTO = {
profileId: string;
pkiCollectionId?: string;
friendlyName?: string;
commonName: string;
commonName?: string;
organization?: string;
organizationUnit?: string;
locality?: string;
@@ -195,7 +195,11 @@ export type TCreateCertificateV3DTO = {
keyAlgorithm?: string;
};
export type TCreateCertificateV3Response = TCreateCertificateResponse;
export type TCreateCertificateV3Response = TCreateCertificateResponse & {
projectId: string;
profileName: string;
certificateId: string;
};
export type TOrderCertificateDTO = {
projectSlug: string;
@@ -52,6 +52,10 @@ export const useRevokeCert = () => {
queryClient.invalidateQueries({
queryKey: pkiSubscriberKeys.allPkiSubscriberCertificates()
});
queryClient.invalidateQueries({
queryKey: ["certificate-profiles", "list"]
});
}
});
};
@@ -12,8 +12,7 @@ import {
Modal,
ModalContent,
Select,
SelectItem,
Switch
SelectItem
// DatePicker
} from "@app/components/v2";
import { useProject } from "@app/context";
@@ -152,7 +151,7 @@ export const CaModal = ({ popUp, handlePopUpToggle }: Props) => {
type: CaType.INTERNAL,
name: "",
status: CaStatus.ACTIVE,
enableDirectIssuance: true,
enableDirectIssuance: false,
configuration: {
type: InternalCaType.ROOT,
organization: "",
@@ -456,23 +455,6 @@ export const CaModal = ({ popUp, handlePopUpToggle }: Props) => {
</FormControl>
)}
/>
<Controller
control={control}
name="enableDirectIssuance"
render={({ field, fieldState: { error } }) => {
return (
<FormControl isError={Boolean(error)} errorText={error?.message} className="my-8">
<Switch
id="enable-direct-issuance"
onCheckedChange={(value) => field.onChange(value)}
isChecked={field.value}
>
<p className="w-full">Enable Direct Issuance</p>
</Switch>
</FormControl>
);
}}
/>
<div className="flex items-center">
<Button
className="mr-4"
@@ -27,10 +27,8 @@ import { useProject } from "@app/context";
import { useCreateCertificateV3, useGetCert } from "@app/hooks/api";
import { useListCertificateProfiles } from "@app/hooks/api/certificateProfiles";
import {
certKeyAlgorithms,
EXTENDED_KEY_USAGES_OPTIONS,
KEY_USAGES_OPTIONS,
SIGNATURE_ALGORITHMS_OPTIONS
KEY_USAGES_OPTIONS
} from "@app/hooks/api/certificates/constants";
import { CertExtendedKeyUsage, CertKeyUsage } from "@app/hooks/api/certificates/enums";
import { useGetCertificateTemplateV2ById } from "@app/hooks/api/certificateTemplates/queries";
@@ -42,49 +40,60 @@ import {
import { CertificateContent } from "./CertificateContent";
const schema = z.object({
profileId: z.string().min(1, "Profile is required"),
subjectAttributes: z
.array(
z.object({
type: z.enum(["common_name"]),
value: z.string().min(1, "Value is required")
})
)
.min(1, "At least one subject attribute is required"),
subjectAltNames: z
.array(
z.object({
type: z.enum(["dns", "ip", "email", "uri"]),
value: z.string().min(1, "Value is required")
})
)
.default([]),
ttl: z.string().trim().min(1, "TTL is required"),
signatureAlgorithm: z.string().optional(),
keyAlgorithm: z.string().optional(),
keyUsages: z.object({
[CertKeyUsage.DIGITAL_SIGNATURE]: z.boolean().optional(),
[CertKeyUsage.KEY_ENCIPHERMENT]: z.boolean().optional(),
[CertKeyUsage.NON_REPUDIATION]: z.boolean().optional(),
[CertKeyUsage.DATA_ENCIPHERMENT]: z.boolean().optional(),
[CertKeyUsage.KEY_AGREEMENT]: z.boolean().optional(),
[CertKeyUsage.KEY_CERT_SIGN]: z.boolean().optional(),
[CertKeyUsage.CRL_SIGN]: z.boolean().optional(),
[CertKeyUsage.ENCIPHER_ONLY]: z.boolean().optional(),
[CertKeyUsage.DECIPHER_ONLY]: z.boolean().optional()
}),
extendedKeyUsages: z.object({
[CertExtendedKeyUsage.CLIENT_AUTH]: z.boolean().optional(),
[CertExtendedKeyUsage.CODE_SIGNING]: z.boolean().optional(),
[CertExtendedKeyUsage.EMAIL_PROTECTION]: z.boolean().optional(),
[CertExtendedKeyUsage.OCSP_SIGNING]: z.boolean().optional(),
[CertExtendedKeyUsage.SERVER_AUTH]: z.boolean().optional(),
[CertExtendedKeyUsage.TIMESTAMPING]: z.boolean().optional()
})
});
const createSchema = (shouldShowSubjectSection: boolean) => {
return z.object({
profileId: z.string().min(1, "Profile is required"),
subjectAttributes: shouldShowSubjectSection
? z
.array(
z.object({
type: z.enum(["common_name"]),
value: z.string().min(1, "Value is required")
})
)
.min(1, "At least one subject attribute is required")
: z
.array(
z.object({
type: z.enum(["common_name"]),
value: z.string().min(1, "Value is required")
})
)
.optional(),
subjectAltNames: z
.array(
z.object({
type: z.enum(["dns", "ip", "email", "uri"]),
value: z.string().min(1, "Value is required")
})
)
.default([]),
ttl: z.string().trim().min(1, "TTL is required"),
signatureAlgorithm: z.string().min(1, "Signature algorithm is required"),
keyAlgorithm: z.string().min(1, "Key algorithm is required"),
keyUsages: z.object({
[CertKeyUsage.DIGITAL_SIGNATURE]: z.boolean().optional(),
[CertKeyUsage.KEY_ENCIPHERMENT]: z.boolean().optional(),
[CertKeyUsage.NON_REPUDIATION]: z.boolean().optional(),
[CertKeyUsage.DATA_ENCIPHERMENT]: z.boolean().optional(),
[CertKeyUsage.KEY_AGREEMENT]: z.boolean().optional(),
[CertKeyUsage.KEY_CERT_SIGN]: z.boolean().optional(),
[CertKeyUsage.CRL_SIGN]: z.boolean().optional(),
[CertKeyUsage.ENCIPHER_ONLY]: z.boolean().optional(),
[CertKeyUsage.DECIPHER_ONLY]: z.boolean().optional()
}),
extendedKeyUsages: z.object({
[CertExtendedKeyUsage.CLIENT_AUTH]: z.boolean().optional(),
[CertExtendedKeyUsage.CODE_SIGNING]: z.boolean().optional(),
[CertExtendedKeyUsage.EMAIL_PROTECTION]: z.boolean().optional(),
[CertExtendedKeyUsage.OCSP_SIGNING]: z.boolean().optional(),
[CertExtendedKeyUsage.SERVER_AUTH]: z.boolean().optional(),
[CertExtendedKeyUsage.TIMESTAMPING]: z.boolean().optional()
})
});
};
export type FormData = z.infer<typeof schema>;
export type FormData = z.infer<ReturnType<typeof createSchema>>;
type Props = {
popUp: UsePopUpState<["certificateIssuance"]>;
@@ -110,6 +119,9 @@ export const CertificateIssuanceModal = ({ popUp, handlePopUpToggle, profileId }
const [requiredExtendedKeyUsages, setRequiredExtendedKeyUsages] = useState<string[]>([]);
const [allowedSignatureAlgorithms, setAllowedSignatureAlgorithms] = useState<string[]>([]);
const [allowedKeyAlgorithms, setAllowedKeyAlgorithms] = useState<string[]>([]);
const [allowedSanTypes, setAllowedSanTypes] = useState<string[]>(["dns", "ip", "email", "uri"]);
const [shouldShowSanSection, setShouldShowSanSection] = useState<boolean>(true);
const [shouldShowSubjectSection, setShouldShowSubjectSection] = useState<boolean>(true);
const { currentProject } = useProject();
const inputSerialNumber =
@@ -133,10 +145,12 @@ export const CertificateIssuanceModal = ({ popUp, handlePopUpToggle, profileId }
setValue,
formState: { isSubmitting }
} = useForm<FormData>({
resolver: zodResolver(schema),
resolver: zodResolver(createSchema(shouldShowSubjectSection)),
defaultValues: {
profileId: profileId || "",
subjectAttributes: [{ type: "common_name", value: "" }],
subjectAttributes: shouldShowSubjectSection
? [{ type: "common_name", value: "" }]
: undefined,
subjectAltNames: [],
ttl: "30d",
signatureAlgorithm: "",
@@ -154,6 +168,9 @@ export const CertificateIssuanceModal = ({ popUp, handlePopUpToggle, profileId }
setRequiredExtendedKeyUsages([]);
setAllowedSignatureAlgorithms([]);
setAllowedKeyAlgorithms([]);
setAllowedSanTypes(["dns", "ip", "email", "uri"]);
setShouldShowSanSection(true);
setShouldShowSubjectSection(true);
reset();
}, [reset]);
@@ -168,21 +185,31 @@ export const CertificateIssuanceModal = ({ popUp, handlePopUpToggle, profileId }
});
const filteredKeyUsages = useMemo(() => {
if (allowedKeyUsages.length === 0) return KEY_USAGES_OPTIONS;
return KEY_USAGES_OPTIONS.filter(({ value }) => allowedKeyUsages.includes(value));
}, [allowedKeyUsages]);
const filteredExtendedKeyUsages = useMemo(() => {
if (allowedExtendedKeyUsages.length === 0) return EXTENDED_KEY_USAGES_OPTIONS;
return EXTENDED_KEY_USAGES_OPTIONS.filter(({ value }) =>
allowedExtendedKeyUsages.includes(value)
);
}, [allowedExtendedKeyUsages]);
const availableSignatureAlgorithms = useMemo(() => {
if (allowedSignatureAlgorithms.length === 0) {
return SIGNATURE_ALGORITHMS_OPTIONS;
const mapBackendSanTypeToFrontend = (backendType: string): string => {
switch (backendType) {
case "dns_name":
return "dns";
case "ip_address":
return "ip";
case "email":
return "email";
case "uri":
return "uri";
default:
return backendType;
}
};
const availableSignatureAlgorithms = useMemo(() => {
return allowedSignatureAlgorithms.map((templateAlgorithm) => {
const apiAlgorithm = mapTemplateSignatureAlgorithmToApi(templateAlgorithm);
return {
@@ -193,9 +220,6 @@ export const CertificateIssuanceModal = ({ popUp, handlePopUpToggle, profileId }
}, [allowedSignatureAlgorithms]);
const availableKeyAlgorithms = useMemo(() => {
if (allowedKeyAlgorithms.length === 0) {
return certKeyAlgorithms;
}
return allowedKeyAlgorithms.map((templateAlgorithm) => {
const apiAlgorithm = mapTemplateKeyAlgorithmToApi(templateAlgorithm);
return {
@@ -247,6 +271,33 @@ export const CertificateIssuanceModal = ({ popUp, handlePopUpToggle, profileId }
setRequiredKeyUsages(templateData.keyUsages?.required || []);
setRequiredExtendedKeyUsages(templateData.extendedKeyUsages?.required || []);
if (templateData.sans && templateData.sans.length > 0) {
const sanTypes: string[] = [];
templateData.sans.forEach((sanPolicy) => {
const frontendType = mapBackendSanTypeToFrontend(sanPolicy.type);
if (!sanTypes.includes(frontendType)) {
sanTypes.push(frontendType);
}
});
setAllowedSanTypes(sanTypes);
setShouldShowSanSection(true);
} else {
setAllowedSanTypes([]);
setShouldShowSanSection(false);
setValue("subjectAltNames", []);
}
if (templateData.subject && templateData.subject.length > 0) {
setShouldShowSubjectSection(true);
const currentSubjectAttrs = watch("subjectAttributes");
if (!currentSubjectAttrs || currentSubjectAttrs.length === 0) {
setValue("subjectAttributes", [{ type: "common_name", value: "" }]);
}
} else {
setShouldShowSubjectSection(false);
setValue("subjectAttributes", undefined);
}
const initialKeyUsages: Record<string, boolean> = {};
const initialExtendedKeyUsages: Record<string, boolean> = {};
@@ -261,7 +312,7 @@ export const CertificateIssuanceModal = ({ popUp, handlePopUpToggle, profileId }
setValue("keyUsages", initialKeyUsages);
setValue("extendedKeyUsages", initialExtendedKeyUsages);
}
}, [templateData, selectedProfile, setValue, popUp?.certificateIssuance?.isOpen]);
}, [templateData, selectedProfile, setValue, watch, popUp?.certificateIssuance?.isOpen]);
useEffect(() => {
if (cert) {
@@ -299,22 +350,19 @@ export const CertificateIssuanceModal = ({ popUp, handlePopUpToggle, profileId }
}, [popUp?.certificateIssuance?.isOpen, profileId, cert, setValue]);
const getAttributeValue = useCallback(
(subjectAttributes: typeof schema._type.subjectAttributes, type: string) => {
const foundAttr = subjectAttributes.find((attr) => attr.type === type);
(subjectAttributes: FormData["subjectAttributes"], type: string) => {
const foundAttr = subjectAttributes?.find((attr) => attr.type === type);
return foundAttr?.value || "";
},
[]
);
const formatSubjectAltNames = useCallback(
(subjectAltNames: typeof schema._type.subjectAltNames) => {
return subjectAltNames
.filter((san) => san.value.trim())
.map((san) => san.value.trim())
.join(", ");
},
[]
);
const formatSubjectAltNames = useCallback((subjectAltNames: FormData["subjectAltNames"]) => {
return subjectAltNames
.filter((san) => san.value.trim())
.map((san) => san.value.trim())
.join(", ");
}, []);
const filterUsages = useCallback(<T extends Record<string, boolean>>(usages: T) => {
return Object.entries(usages)
@@ -350,28 +398,40 @@ export const CertificateIssuanceModal = ({ popUp, handlePopUpToggle, profileId }
return;
}
const commonName = getAttributeValue(subjectAttributes, "common_name");
if (!commonName.trim()) {
createNotification({
text: "Common name is required.",
type: "error"
});
return;
let commonName = "";
if (shouldShowSubjectSection && subjectAttributes && subjectAttributes.length > 0) {
commonName = getAttributeValue(subjectAttributes, "common_name");
if (!commonName.trim()) {
createNotification({
text: "Common name is required.",
type: "error"
});
return;
}
}
const { serialNumber, certificate, certificateChain, privateKey } = await createCertificate(
{
profileId: formProfileId,
projectSlug: currentProject.slug,
commonName,
subjectAltNames: formatSubjectAltNames(subjectAltNames),
ttl,
signatureAlgorithm,
keyAlgorithm,
keyUsages: filterUsages(keyUsages) as CertKeyUsage[],
extendedKeyUsages: filterUsages(extendedKeyUsages) as CertExtendedKeyUsage[]
const certificateRequest: any = {
profileId: formProfileId,
projectSlug: currentProject.slug,
ttl,
signatureAlgorithm,
keyAlgorithm,
keyUsages: filterUsages(keyUsages) as CertKeyUsage[],
extendedKeyUsages: filterUsages(extendedKeyUsages) as CertExtendedKeyUsage[]
};
if (shouldShowSubjectSection && commonName) {
certificateRequest.commonName = commonName;
}
if (shouldShowSanSection && subjectAltNames && subjectAltNames.length > 0) {
const formattedSans = formatSubjectAltNames(subjectAltNames);
if (formattedSans) {
certificateRequest.subjectAltNames = formattedSans;
}
);
}
const { serialNumber, certificate, certificateChain, privateKey } =
await createCertificate(certificateRequest);
setCertificateDetails({
serialNumber,
@@ -399,7 +459,8 @@ export const CertificateIssuanceModal = ({ popUp, handlePopUpToggle, profileId }
[
currentProject?.slug,
createCertificate,
reset,
shouldShowSubjectSection,
shouldShowSanSection,
getAttributeValue,
formatSubjectAltNames,
filterUsages
@@ -517,47 +578,132 @@ export const CertificateIssuanceModal = ({ popUp, handlePopUpToggle, profileId }
{(selectedProfile || profileId) && (
<>
<Controller
control={control}
name="subjectAttributes"
render={({ field: { onChange, value }, fieldState: { error } }) => (
<FormControl
label="Subject Attributes"
isRequired
errorText={error?.message}
isError={Boolean(error)}
>
<div className="space-y-2">
{value.map((attr, index) => (
// eslint-disable-next-line react/no-array-index-key
<div key={`subject-attr-${index}`} className="flex items-center gap-2">
<Select
value={attr.type}
onValueChange={(newType) => {
const newValue = [...value];
newValue[index] = {
...attr,
type: newType as typeof attr.type
};
onChange(newValue);
}}
className="w-48"
{shouldShowSubjectSection && (
<Controller
control={control}
name="subjectAttributes"
render={({ field: { onChange, value }, fieldState: { error } }) => (
<FormControl
label="Subject Attributes"
isRequired
errorText={error?.message}
isError={Boolean(error)}
>
<div className="space-y-2">
{(value || []).map((attr, index) => (
// eslint-disable-next-line react/no-array-index-key
<div key={`subject-attr-${index}`} className="flex items-center gap-2">
<Select
value={attr.type}
onValueChange={(newType) => {
const newValue = [...(value || [])];
newValue[index] = {
...attr,
type: newType as typeof attr.type
};
onChange(newValue);
}}
className="w-48"
>
<SelectItem value="common_name">Common Name</SelectItem>
</Select>
<Input
value={attr.value}
onChange={(e) => {
const newValue = [...(value || [])];
newValue[index] = { ...attr, value: e.target.value };
onChange(newValue);
}}
placeholder="example.com"
className="flex-1"
/>
{(value || []).length > 1 && (
<IconButton
ariaLabel="Remove Subject Attribute"
variant="plain"
size="sm"
onClick={() => {
const newValue = (value || []).filter((_, i) => i !== index);
onChange(newValue);
}}
>
<FontAwesomeIcon icon={faTrash} />
</IconButton>
)}
</div>
))}
<Button
type="button"
variant="outline_bg"
size="xs"
leftIcon={<FontAwesomeIcon icon={faPlus} />}
onClick={() => {
onChange([...(value || []), { type: "common_name", value: "" }]);
}}
className="w-full"
>
Add Subject Attribute
</Button>
</div>
</FormControl>
)}
/>
)}
{shouldShowSanSection && (
<Controller
control={control}
name="subjectAltNames"
render={({ field: { onChange, value }, fieldState: { error } }) => (
<FormControl
label="Subject Alternative Names (SANs)"
errorText={error?.message}
isError={Boolean(error)}
>
<div className="space-y-2">
{value.map((san, index) => (
<div
// eslint-disable-next-line react/no-array-index-key
key={`subject-alt-name-${index}`}
className="flex items-center gap-2"
>
<SelectItem value="common_name">Common Name</SelectItem>
</Select>
<Input
value={attr.value}
onChange={(e) => {
const newValue = [...value];
newValue[index] = { ...attr, value: e.target.value };
onChange(newValue);
}}
placeholder="example.com"
className="flex-1"
/>
{value.length > 1 && (
<Select
value={san.type}
onValueChange={(newType) => {
const newValue = [...value];
newValue[index] = {
...san,
type: newType as "dns" | "ip" | "email" | "uri"
};
onChange(newValue);
}}
className="w-24"
>
{allowedSanTypes.includes("dns") && (
<SelectItem value="dns">DNS</SelectItem>
)}
{allowedSanTypes.includes("ip") && (
<SelectItem value="ip">IP</SelectItem>
)}
{allowedSanTypes.includes("email") && (
<SelectItem value="email">Email</SelectItem>
)}
{allowedSanTypes.includes("uri") && (
<SelectItem value="uri">URI</SelectItem>
)}
</Select>
<Input
value={san.value}
onChange={(e) => {
const newValue = [...value];
newValue[index] = { ...san, value: e.target.value };
onChange(newValue);
}}
placeholder={getSanPlaceholder(san.type)}
className="flex-1"
/>
<IconButton
ariaLabel="Remove Subject Attribute"
ariaLabel="Remove SAN"
variant="plain"
size="sm"
onClick={() => {
@@ -567,98 +713,30 @@ export const CertificateIssuanceModal = ({ popUp, handlePopUpToggle, profileId }
>
<FontAwesomeIcon icon={faTrash} />
</IconButton>
)}
</div>
))}
<Button
type="button"
variant="outline_bg"
size="xs"
leftIcon={<FontAwesomeIcon icon={faPlus} />}
onClick={() => {
onChange([...value, { type: "common_name", value: "" }]);
}}
className="w-full"
>
Add Subject Attribute
</Button>
</div>
</FormControl>
)}
/>
<Controller
control={control}
name="subjectAltNames"
render={({ field: { onChange, value }, fieldState: { error } }) => (
<FormControl
label="Subject Alternative Names (SANs)"
errorText={error?.message}
isError={Boolean(error)}
>
<div className="space-y-2">
{value.map((san, index) => (
<div
// eslint-disable-next-line react/no-array-index-key
key={`subject-alt-name-${index}`}
className="flex items-center gap-2"
</div>
))}
<Button
type="button"
variant="outline_bg"
size="xs"
leftIcon={<FontAwesomeIcon icon={faPlus} />}
onClick={() => {
const defaultType =
allowedSanTypes.length > 0 ? allowedSanTypes[0] : "dns";
onChange([
...value,
{ type: defaultType as "dns" | "ip" | "email" | "uri", value: "" }
]);
}}
className="w-full"
>
<Select
value={san.type}
onValueChange={(newType) => {
const newValue = [...value];
newValue[index] = {
...san,
type: newType as "dns" | "ip" | "email" | "uri"
};
onChange(newValue);
}}
className="w-24"
>
<SelectItem value="dns">DNS</SelectItem>
<SelectItem value="ip">IP</SelectItem>
<SelectItem value="email">Email</SelectItem>
<SelectItem value="uri">URI</SelectItem>
</Select>
<Input
value={san.value}
onChange={(e) => {
const newValue = [...value];
newValue[index] = { ...san, value: e.target.value };
onChange(newValue);
}}
placeholder={getSanPlaceholder(san.type)}
className="flex-1"
/>
<IconButton
ariaLabel="Remove SAN"
variant="plain"
size="sm"
onClick={() => {
const newValue = value.filter((_, i) => i !== index);
onChange(newValue);
}}
>
<FontAwesomeIcon icon={faTrash} />
</IconButton>
</div>
))}
<Button
type="button"
variant="outline_bg"
size="xs"
leftIcon={<FontAwesomeIcon icon={faPlus} />}
onClick={() => {
onChange([...value, { type: "dns", value: "" }]);
}}
className="w-full"
>
Add SAN
</Button>
</div>
</FormControl>
)}
/>
Add SAN
</Button>
</div>
</FormControl>
)}
/>
)}
<Controller
control={control}
@@ -744,85 +822,89 @@ export const CertificateIssuanceModal = ({ popUp, handlePopUpToggle, profileId }
</div>
<Accordion type="single" collapsible className="w-full">
<AccordionItem value="key-usages">
<AccordionTrigger>Key Usages</AccordionTrigger>
<AccordionContent>
<div className="grid grid-cols-2 gap-2 pl-2">
{filteredKeyUsages.map(({ label, value }) => {
const isRequired = requiredKeyUsages.includes(value);
return (
<Controller
key={label}
control={control}
name={`keyUsages.${value}` as any}
render={({ field }) => (
<div className="flex items-center space-x-3">
<Checkbox
id={`key-usage-${value}`}
isChecked={field.value || false}
onCheckedChange={(checked) => {
if (!isRequired) {
field.onChange(checked);
}
}}
isDisabled={isRequired}
/>
<div className="flex items-center gap-2">
<FormLabel
{filteredKeyUsages.length > 0 && (
<AccordionItem value="key-usages">
<AccordionTrigger>Key Usages</AccordionTrigger>
<AccordionContent>
<div className="grid grid-cols-2 gap-2 pl-2">
{filteredKeyUsages.map(({ label, value }) => {
const isRequired = requiredKeyUsages.includes(value);
return (
<Controller
key={label}
control={control}
name={`keyUsages.${value}` as any}
render={({ field }) => (
<div className="flex items-center space-x-3">
<Checkbox
id={`key-usage-${value}`}
className={`text-sm ${isRequired ? "text-mineshaft-200" : "cursor-pointer text-mineshaft-300"}`}
label={label}
isChecked={field.value || false}
onCheckedChange={(checked) => {
if (!isRequired) {
field.onChange(checked);
}
}}
isDisabled={isRequired}
/>
{isRequired && <span className="text-xs">(Required)</span>}
<div className="flex items-center gap-2">
<FormLabel
id={`key-usage-${value}`}
className={`text-sm ${isRequired ? "text-mineshaft-200" : "cursor-pointer text-mineshaft-300"}`}
label={label}
/>
{isRequired && <span className="text-xs">(Required)</span>}
</div>
</div>
</div>
)}
/>
);
})}
</div>
</AccordionContent>
</AccordionItem>
)}
/>
);
})}
</div>
</AccordionContent>
</AccordionItem>
)}
<AccordionItem value="extended-key-usages">
<AccordionTrigger>Extended Key Usages</AccordionTrigger>
<AccordionContent>
<div className="grid grid-cols-2 gap-2 pl-2">
{filteredExtendedKeyUsages.map(({ label, value }) => {
const isRequired = requiredExtendedKeyUsages.includes(value);
return (
<Controller
key={label}
control={control}
name={`extendedKeyUsages.${value}` as any}
render={({ field }) => (
<div className="flex items-center space-x-3">
<Checkbox
id={`ext-key-usage-${value}`}
isChecked={field.value || false}
onCheckedChange={(checked) => {
if (!isRequired) {
field.onChange(checked);
}
}}
isDisabled={isRequired}
/>
<div className="flex items-center gap-2">
<FormLabel
{filteredExtendedKeyUsages.length > 0 && (
<AccordionItem value="extended-key-usages">
<AccordionTrigger>Extended Key Usages</AccordionTrigger>
<AccordionContent>
<div className="grid grid-cols-2 gap-2 pl-2">
{filteredExtendedKeyUsages.map(({ label, value }) => {
const isRequired = requiredExtendedKeyUsages.includes(value);
return (
<Controller
key={label}
control={control}
name={`extendedKeyUsages.${value}` as any}
render={({ field }) => (
<div className="flex items-center space-x-3">
<Checkbox
id={`ext-key-usage-${value}`}
className={`text-sm ${isRequired ? "text-mineshaft-200" : "cursor-pointer text-mineshaft-300"}`}
label={label}
isChecked={field.value || false}
onCheckedChange={(checked) => {
if (!isRequired) {
field.onChange(checked);
}
}}
isDisabled={isRequired}
/>
{isRequired && <span className="text-xs">(Required)</span>}
<div className="flex items-center gap-2">
<FormLabel
id={`ext-key-usage-${value}`}
className={`text-sm ${isRequired ? "text-mineshaft-200" : "cursor-pointer text-mineshaft-300"}`}
label={label}
/>
{isRequired && <span className="text-xs">(Required)</span>}
</div>
</div>
</div>
)}
/>
);
})}
</div>
</AccordionContent>
</AccordionItem>
)}
/>
);
})}
</div>
</AccordionContent>
</AccordionItem>
)}
</Accordion>
</>
)}
@@ -52,7 +52,7 @@ export const PoliciesPage = () => {
/>
<Tabs value={activeTab} onValueChange={(value) => setActiveTab(value as TabSections)}>
<TabList className="mb-6 w-full">
<TabList className="w-full">
<div className="flex w-full border-b border-mineshaft-600">
<Tab value={TabSections.CertificateProfiles}>Certificate Profiles</Tab>
<Tab value={TabSections.CertificateTemplatesV2}>Certificate Templates</Tab>
@@ -27,8 +27,20 @@ import { useListCertificateTemplatesV2 } from "@app/hooks/api/certificateTemplat
const createSchema = z
.object({
slug: z.string().trim().min(1, "Profile slug is required"),
description: z.string().optional(),
slug: z
.string()
.trim()
.min(1, "Profile slug is required")
.max(255, "Profile slug must be less than 255 characters")
.regex(
/^[a-zA-Z0-9-_]+$/,
"Profile slug must contain only letters, numbers, hyphens, and underscores"
),
description: z
.string()
.trim()
.max(1000, "Description must be less than 1000 characters")
.optional(),
enrollmentType: z.enum(["api", "est"]),
certificateAuthorityId: z.string().min(1, "Certificate Authority is required"),
certificateTemplateId: z.string().min(1, "Certificate Template is required"),
@@ -36,8 +48,19 @@ const createSchema = z
.object({
disableBootstrapCaValidation: z.boolean().optional(),
passphrase: z.string().min(1, "EST passphrase is required"),
caChain: z.string().min(1, "EST CA chain is required")
caChain: z.string().min(1, "EST CA chain is required").optional()
})
.refine(
(data) => {
if (!data.disableBootstrapCaValidation && !data.caChain) {
return false;
}
return true;
},
{
message: "EST CA chain is required"
}
)
.optional(),
apiConfig: z
.object({
@@ -63,8 +86,20 @@ const createSchema = z
const editSchema = z
.object({
slug: z.string().trim().min(1, "Profile slug is required"),
description: z.string().optional(),
slug: z
.string()
.trim()
.min(1, "Profile slug is required")
.max(255, "Profile slug must be less than 255 characters")
.regex(
/^[a-zA-Z0-9-_]+$/,
"Profile slug must contain only letters, numbers, hyphens, and underscores"
),
description: z
.string()
.trim()
.max(1000, "Description must be less than 1000 characters")
.optional(),
enrollmentType: z.enum(["api", "est"]),
certificateAuthorityId: z.string().optional(),
certificateTemplateId: z.string().optional(),
@@ -136,7 +171,7 @@ export const CreateProfileModal = ({ isOpen, onClose, profile, mode = "create" }
estConfig: {
disableBootstrapCaValidation: profile.estConfig?.disableBootstrapCaValidation || false,
passphrase: "",
caChain: ""
caChain: undefined
},
apiConfig: {
autoRenew: profile.apiConfig?.autoRenew || false,
@@ -193,7 +228,11 @@ export const CreateProfileModal = ({ isOpen, onClose, profile, mode = "create" }
};
if (data.enrollmentType === "est" && data.estConfig) {
createData.estConfig = data.estConfig;
createData.estConfig = {
passphrase: data.estConfig.passphrase,
caChain: data.estConfig.caChain || "",
disableBootstrapCaValidation: data.estConfig.disableBootstrapCaValidation
};
} else if (data.enrollmentType === "api" && data.apiConfig) {
createData.apiConfig = data.apiConfig;
}
@@ -241,7 +280,7 @@ export const CreateProfileModal = ({ isOpen, onClose, profile, mode = "create" }
name="slug"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Profile Slug"
label="Name"
isRequired
isError={Boolean(error)}
errorText={error?.message}
@@ -150,7 +150,7 @@ export const ProfileRow = ({ profile, onEditProfile, onDeleteProfile }: Props) =
<Tr key={profile.id} className="h-10 transition-colors duration-100 hover:bg-mineshaft-700">
<Td>
<div className="flex items-center gap-2">
<div className="font-medium text-mineshaft-100">{profile.slug}</div>
<div className="text-mineshaft-300">{profile.slug}</div>
{profile.description && (
<Tooltip content={profile.description}>
<FontAwesomeIcon icon={faCircleInfo} className="text-mineshaft-400" />
@@ -362,8 +362,8 @@ export const CreateTemplateModal = ({ isOpen, onClose, template, mode = "create"
return {
name: data.name,
description: data.description,
subject: subject.length > 0 ? subject : undefined,
sans: sans.length > 0 ? sans : undefined,
subject,
sans,
keyUsages: Object.keys(keyUsages).length > 0 ? keyUsages : undefined,
extendedKeyUsages: Object.keys(extendedKeyUsages).length > 0 ? extendedKeyUsages : undefined,
algorithms: Object.keys(algorithms).length > 0 ? algorithms : undefined,
@@ -614,7 +614,7 @@ export const CreateTemplateModal = ({ isOpen, onClose, template, mode = "create"
required
/>
{watchedAttributes.length > 1 && (
{watchedAttributes.length > 0 && (
<IconButton
ariaLabel="Remove Attribute"
variant="plain"
@@ -672,7 +672,7 @@ export const CreateTemplateModal = ({ isOpen, onClose, template, mode = "create"
};
setValue("subjectAlternativeNames", newSans);
}}
className="w-24"
className="w-36"
>
{SAN_TYPE_OPTIONS.map((type) => (
<SelectItem key={type} value={type}>
@@ -719,7 +719,7 @@ export const CreateTemplateModal = ({ isOpen, onClose, template, mode = "create"
required
/>
{watchedSans.length > 1 && (
{watchedSans.length > 0 && (
<IconButton
ariaLabel="Remove SAN"
variant="plain"
@@ -755,7 +755,7 @@ export const CreateTemplateModal = ({ isOpen, onClose, template, mode = "create"
<div className="space-y-6">
<div>
<h4 className="mb-3 text-sm font-medium text-mineshaft-200">
Signature Algorithms
Allowed Signature Algorithms
</h4>
<Controller
control={control}
@@ -799,7 +799,9 @@ export const CreateTemplateModal = ({ isOpen, onClose, template, mode = "create"
</div>
<div>
<h4 className="mb-3 text-sm font-medium text-mineshaft-200">Key Algorithms</h4>
<h4 className="mb-3 text-sm font-medium text-mineshaft-200">
Allowed Key Algorithms
</h4>
<Controller
control={control}
name="keyAlgorithm.allowedKeyTypes"
@@ -89,7 +89,7 @@ export const TemplateList = ({ onEditTemplate, onDeleteTemplate }: Props) => {
>
<Td>
<div className="flex items-center gap-2">
<div className="font-medium">{template.name}</div>
<div className="text-mineshaft-300">{template.name}</div>
{template.description && (
<Tooltip content={template.description}>
<FontAwesomeIcon icon={faCircleInfo} className="text-mineshaft-400" />
@@ -50,8 +50,20 @@ export const uiKeyAlgorithmSchema = z.object({
});
export const templateSchema = z.object({
name: z.string().trim().min(1, "Template name is required"),
description: z.string().optional(),
name: z
.string()
.trim()
.min(1, "Template name is required")
.max(255, "Template name must be less than 255 characters")
.regex(
/^[a-zA-Z0-9-_]+$/,
"Template name must contain only letters, numbers, hyphens, and underscores"
),
description: z
.string()
.trim()
.max(1000, "Description must be less than 1000 characters")
.optional(),
attributes: z.array(uiAttributeSchema).optional(),
subjectAlternativeNames: z.array(uiSanSchema).optional(),
keyUsages: uiKeyUsagesSchema.optional(),
@@ -86,8 +98,20 @@ export const apiSanSchema = z
});
export const apiTemplateSchema = z.object({
name: z.string().trim().min(1, "Template name is required"),
description: z.string().optional(),
name: z
.string()
.trim()
.min(1, "Template name is required")
.max(255, "Template name must be less than 255 characters")
.regex(
/^[a-zA-Z0-9-_]+$/,
"Template name must contain only letters, numbers, hyphens, and underscores"
),
description: z
.string()
.trim()
.max(1000, "Description must be less than 1000 characters")
.optional(),
subject: z.array(apiSubjectSchema).optional(),
sans: z.array(apiSanSchema).optional(),
keyUsages: z
@@ -106,14 +130,15 @@ export const apiTemplateSchema = z.object({
.optional(),
algorithms: z
.object({
signature: z.array(z.string()).optional(),
keyAlgorithm: z.array(z.string()).optional()
signature: z.array(z.string().trim().min(1, "Algorithm cannot be empty")).optional(),
keyAlgorithm: z.array(z.string().trim().min(1, "Algorithm cannot be empty")).optional()
})
.optional(),
validity: z
.object({
max: z
.string()
.trim()
.regex(/^[1-9]\d*[dhmy]$/, "Must be in format like '365d', '12m', '1y', or '24h'")
.optional()
})
@@ -393,12 +393,8 @@ export const projectRoleFormSchema = z.object({
})
.array()
.default([]),
[ProjectPermissionSub.CertificateProfiles]: CertificateProfilePolicyActionSchema.extend({
inverted: z.boolean().optional(),
conditions: ConditionSchema
})
.array()
.default([]),
[ProjectPermissionSub.CertificateProfiles]:
CertificateProfilePolicyActionSchema.array().default([]),
[ProjectPermissionSub.SshCertificateAuthorities]: GeneralPolicyActionSchema.array().default(
[]
),
@@ -479,7 +475,6 @@ export const isConditionalSubjects = (
subject === ProjectPermissionSub.SecretRotation ||
subject === ProjectPermissionSub.PkiSubscribers ||
subject === ProjectPermissionSub.CertificateTemplates ||
subject === ProjectPermissionSub.CertificateProfiles ||
subject === ProjectPermissionSub.SecretSyncs ||
subject === ProjectPermissionSub.PkiSyncs ||
subject === ProjectPermissionSub.SecretEvents ||
@@ -1176,9 +1171,7 @@ export const rolePermission2Form = (permissions: TProjectPermission[] = []) => {
),
[ProjectPermissionCertificateProfileActions.IssueCert]: action.includes(
ProjectPermissionCertificateProfileActions.IssueCert
),
conditions: conditions ? convertCaslConditionToFormOperator(conditions) : [],
inverted
)
});
return;
@@ -2195,6 +2188,10 @@ export const RoleTemplates: Record<ProjectType, RoleTemplate[]> = {
{
subject: ProjectPermissionSub.PkiSyncs,
actions: [ProjectPermissionPkiSyncActions.Read]
},
{
subject: ProjectPermissionSub.CertificateProfiles,
actions: [ProjectPermissionCertificateProfileActions.Read]
}
]
},
@@ -2226,6 +2223,10 @@ export const RoleTemplates: Record<ProjectType, RoleTemplate[]> = {
{
subject: ProjectPermissionSub.PkiSyncs,
actions: Object.values(ProjectPermissionPkiSyncActions)
},
{
subject: ProjectPermissionSub.CertificateProfiles,
actions: Object.values(ProjectPermissionCertificateProfileActions)
}
]
},