Addressed PR comments

This commit is contained in:
Carlos Monastyrski
2025-10-14 03:18:35 -03:00
parent 073be13906
commit 42800fdfe5
82 changed files with 4223 additions and 1956 deletions
@@ -4,6 +4,7 @@ export {
ProjectPermissionActions,
ProjectPermissionAuditLogsActions,
ProjectPermissionCertificateActions,
ProjectPermissionCertificateProfileActions,
ProjectPermissionCmekActions,
ProjectPermissionDynamicSecretActions,
ProjectPermissionGroupActions,
@@ -124,6 +124,14 @@ export enum ProjectPermissionPkiTemplateActions {
ListCerts = "list-certs"
}
export enum ProjectPermissionCertificateProfileActions {
Read = "read",
Create = "create",
Edit = "edit",
Delete = "delete",
IssueCert = "issue-cert"
}
export enum ProjectPermissionSecretRotationActions {
Read = "read",
ReadGeneratedCredentials = "read-generated-credentials",
@@ -217,6 +225,7 @@ export type ConditionalProjectPermissionSubject =
| ProjectPermissionSub.SshHosts
| ProjectPermissionSub.PkiSubscribers
| ProjectPermissionSub.CertificateTemplates
| ProjectPermissionSub.CertificateProfiles
| ProjectPermissionSub.SecretFolders
| ProjectPermissionSub.SecretImports
| ProjectPermissionSub.SecretRotation
@@ -293,6 +302,7 @@ export enum ProjectPermissionSub {
PkiAlerts = "pki-alerts",
PkiCollections = "pki-collections",
PkiSubscribers = "pki-subscribers",
CertificateProfiles = "certificate-profiles",
Kms = "kms",
Cmek = "cmek",
SecretSyncs = "secret-syncs",
@@ -470,6 +480,7 @@ export type ProjectPermissionSet =
| (ForcedSubject<ProjectPermissionSub.PkiSubscribers> & PkiSubscriberSubjectFields)
)
]
| [ProjectPermissionCertificateProfileActions, ProjectPermissionSub.CertificateProfiles]
| [ProjectPermissionActions, ProjectPermissionSub.PkiAlerts]
| [ProjectPermissionActions, ProjectPermissionSub.PkiCollections]
| [ProjectPermissionActions.Delete, ProjectPermissionSub.Project]
+1
View File
@@ -15,6 +15,7 @@ export {
ProjectPermissionActions,
ProjectPermissionAuditLogsActions,
ProjectPermissionCertificateActions,
ProjectPermissionCertificateProfileActions,
ProjectPermissionCmekActions,
ProjectPermissionDynamicSecretActions,
ProjectPermissionGroupActions,
+1 -1
View File
@@ -79,7 +79,7 @@ export const getProjectHomePage = (type: ProjectType, environments: ProjectEnv[]
case ProjectType.SecretManager:
return "/projects/secret-management/$projectId/overview" as const;
case ProjectType.CertificateManager:
return "/projects/cert-management/$projectId/subscribers" as const;
return "/projects/cert-management/$projectId/policies" as const;
case ProjectType.SecretScanning:
return `/projects/${type}/$projectId/data-sources` as const;
case ProjectType.PAM:
+2
View File
@@ -1,10 +1,12 @@
export { AcmeDnsProvider, CaRenewalType, CaStatus, CaType, InternalCaType } from "./enums";
export type { TOrderCertificateDTO, TOrderCertificateResponse } from "./types";
export {
useCreateCa,
useCreateCertificate,
useCreateCertificateV3,
useDeleteCa,
useImportCaCertificate,
useOrderCertificateWithProfile,
useRenewCa,
useSignIntermediate,
useUpdateCa
+20
View File
@@ -14,6 +14,8 @@ import {
TDeleteCertificateAuthorityDTO,
TImportCaCertificateDTO,
TImportCaCertificateResponse,
TOrderCertificateDTO,
TOrderCertificateResponse,
TRenewCaDTO,
TRenewCaResponse,
TSignIntermediateDTO,
@@ -168,6 +170,24 @@ export const useCreateCertificateV3 = () => {
});
};
export const useOrderCertificateWithProfile = () => {
const queryClient = useQueryClient();
return useMutation<TOrderCertificateResponse, object, TOrderCertificateDTO>({
mutationFn: async (body) => {
const { data } = await apiRequest.post<TOrderCertificateResponse>(
"/api/v3/certificates/order-certificate",
body
);
return data;
},
onSuccess: (_, { projectSlug }) => {
queryClient.invalidateQueries({
queryKey: projectKeys.forProjectCertificates(projectSlug)
});
}
});
};
export const useRenewCa = () => {
const queryClient = useQueryClient();
return useMutation<TRenewCaResponse, object, TRenewCaDTO>({
+50 -2
View File
@@ -155,7 +155,7 @@ export type TCreateCertificateDTO = {
pkiCollectionId?: string;
friendlyName?: string;
commonName: string;
altNames: string; // sans
subjectAltNames: string; // sans
ttl: string; // string compatible with ms
notBefore?: string;
notAfter?: string;
@@ -185,7 +185,7 @@ export type TCreateCertificateV3DTO = {
email?: string;
streetAddress?: string;
postalCode?: string;
altNames: string;
subjectAltNames: string;
ttl: string;
notBefore?: string;
notAfter?: string;
@@ -197,6 +197,54 @@ export type TCreateCertificateV3DTO = {
export type TCreateCertificateV3Response = TCreateCertificateResponse;
export type TOrderCertificateDTO = {
projectSlug: string;
profileId: string;
subjectAlternativeNames: Array<{
type: "dns" | "ip";
value: string;
}>;
ttl: string;
keyUsages?: CertKeyUsage[];
extendedKeyUsages?: CertExtendedKeyUsage[];
notBefore?: string;
notAfter?: string;
commonName?: string;
signatureAlgorithm?: string;
keyAlgorithm?: string;
};
export type TOrderCertificateResponse = {
orderId: string;
status: "pending" | "processing" | "valid" | "invalid";
subjectAlternativeNames: Array<{
type: "dns" | "ip";
value: string;
status: "pending" | "processing" | "valid" | "invalid";
}>;
authorizations: Array<{
identifier: {
type: "dns" | "ip";
value: string;
};
status: "pending" | "processing" | "valid" | "invalid";
expires?: string;
challenges: Array<{
type: string;
status: "pending" | "processing" | "valid" | "invalid";
url: string;
token: string;
validated?: string;
error?: any;
}>;
}>;
certificate?: string;
privateKey?: string;
expires: string;
notBefore: string;
notAfter: string;
};
export type TRenewCaDTO = {
projectSlug: string;
caId: string;
@@ -124,16 +124,7 @@ export type TListCertificateTemplatesDTO = {
export type TCertificateTemplateV2Policy = {
attributes: Array<{
type:
| "common_name"
| "organization_name"
| "organization_unit"
| "locality"
| "state"
| "country"
| "email"
| "street_address"
| "postal_code";
type: "common_name";
include: "mandatory" | "optional" | "prohibit";
value?: string[];
}>;
+15 -15
View File
@@ -25,22 +25,22 @@ export enum CrlReason {
}
export enum CertKeyUsage {
DIGITAL_SIGNATURE = "digitalSignature",
KEY_ENCIPHERMENT = "keyEncipherment",
NON_REPUDIATION = "nonRepudiation",
DATA_ENCIPHERMENT = "dataEncipherment",
KEY_AGREEMENT = "keyAgreement",
KEY_CERT_SIGN = "keyCertSign",
CRL_SIGN = "cRLSign",
ENCIPHER_ONLY = "encipherOnly",
DECIPHER_ONLY = "decipherOnly"
DIGITAL_SIGNATURE = "digital_signature",
KEY_ENCIPHERMENT = "key_encipherment",
NON_REPUDIATION = "non_repudiation",
DATA_ENCIPHERMENT = "data_encipherment",
KEY_AGREEMENT = "key_agreement",
KEY_CERT_SIGN = "key_cert_sign",
CRL_SIGN = "crl_sign",
ENCIPHER_ONLY = "encipher_only",
DECIPHER_ONLY = "decipher_only"
}
export enum CertExtendedKeyUsage {
CLIENT_AUTH = "clientAuth",
SERVER_AUTH = "serverAuth",
CODE_SIGNING = "codeSigning",
EMAIL_PROTECTION = "emailProtection",
TIMESTAMPING = "timeStamping",
OCSP_SIGNING = "ocspSigning"
CLIENT_AUTH = "client_auth",
SERVER_AUTH = "server_auth",
CODE_SIGNING = "code_signing",
EMAIL_PROTECTION = "email_protection",
TIMESTAMPING = "time_stamping",
OCSP_SIGNING = "ocsp_signing"
}
+1 -1
View File
@@ -7,7 +7,7 @@ export type TCertificate = {
status: CertStatus;
friendlyName: string;
commonName: string;
altNames: string;
subjectAltNames: string;
serialNumber: string;
notBefore: string;
notAfter: string;
@@ -18,7 +18,7 @@ import { Link, Outlet } from "@tanstack/react-router";
import { motion } from "framer-motion";
import { Lottie, Menu, MenuGroup, MenuItem } from "@app/components/v2";
import { useProject, useProjectPermission } from "@app/context";
import { useProject, useProjectPermission, useSubscription } from "@app/context";
import {
useListWorkspaceCertificateTemplates,
useListWorkspacePkiSubscribers
@@ -29,6 +29,7 @@ import { AssumePrivilegeModeBanner } from "../ProjectLayout/components/AssumePri
export const PkiManagerLayout = () => {
const { currentProject } = useProject();
const { assumedPrivilegeDetails } = useProjectPermission();
const { subscription } = useSubscription();
const { t } = useTranslation();
const { data: subscribers = [] } = useListWorkspacePkiSubscribers(currentProject?.id || "");
@@ -39,7 +40,8 @@ export const PkiManagerLayout = () => {
const hasExistingSubscribers = subscribers.length > 0;
const hasExistingTemplates = templates.length > 0;
const showLegacySection = hasExistingSubscribers || hasExistingTemplates;
const showLegacySection =
subscription.pkiLegacyTemplates || hasExistingSubscribers || hasExistingTemplates;
return (
<>
@@ -166,7 +168,7 @@ export const PkiManagerLayout = () => {
</MenuGroup>
{showLegacySection && (
<MenuGroup title="Legacy">
{hasExistingSubscribers && (
{(subscription.pkiLegacyTemplates || hasExistingSubscribers) && (
<Link
to="/projects/cert-management/$projectId/subscribers"
params={{
@@ -185,7 +187,7 @@ export const PkiManagerLayout = () => {
)}
</Link>
)}
{hasExistingTemplates && (
{(subscription.pkiLegacyTemplates || hasExistingTemplates) && (
<Link
to="/projects/cert-management/$projectId/certificate-templates"
params={{
@@ -7,7 +7,6 @@ import { createNotification } from "@app/components/notifications";
import {
Button,
FormControl,
Input,
Modal,
ModalContent,
Select,
@@ -24,8 +23,6 @@ const schema = z.object({
certificatePem: z.string().trim().min(1, "Certificate PEM is required"),
privateKeyPem: z.string().trim().min(1, "Private Key PEM is required"),
chainPem: z.string().trim().min(1, "Certificate Chain PEM is required"),
friendlyName: z.string(),
collectionId: z.string().optional()
});
@@ -72,7 +69,6 @@ export const CertificateImportModal = ({ popUp, handlePopUpToggle }: Props) => {
certificatePem,
privateKeyPem,
chainPem,
friendlyName,
collectionId
}: FormData) => {
try {
@@ -84,8 +80,6 @@ export const CertificateImportModal = ({ popUp, handlePopUpToggle }: Props) => {
certificatePem,
privateKeyPem,
chainPem,
friendlyName,
pkiCollectionId: collectionId
});
@@ -150,20 +144,6 @@ export const CertificateImportModal = ({ popUp, handlePopUpToggle }: Props) => {
</FormControl>
)}
/>
<Controller
control={control}
defaultValue=""
name="friendlyName"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Friendly Name"
isError={Boolean(error)}
errorText={error?.message}
>
<Input {...field} placeholder="My Certificate" isDisabled={Boolean(cert)} />
</FormControl>
)}
/>
<Controller
control={control}
defaultValue=""
@@ -46,9 +46,8 @@ const schema = z.object({
certificateTemplateId: z.string().optional(),
caId: z.string(),
collectionId: z.string().optional(),
friendlyName: z.string(),
commonName: z.string().trim().min(1),
altNames: z.string(),
subjectAltNames: z.string(),
ttl: z.string().trim(),
keyUsages: z.object({
[CertKeyUsage.DIGITAL_SIGNATURE]: z.boolean().optional(),
@@ -139,9 +138,8 @@ export const CertificateModal = ({ popUp, handlePopUpToggle }: Props) => {
if (cert) {
reset({
caId: cert.caId,
friendlyName: cert.friendlyName,
commonName: cert.commonName,
altNames: cert.altNames,
subjectAltNames: cert.subjectAltNames,
certificateTemplateId: cert.certificateTemplateId ?? CERT_TEMPLATE_NONE_VALUE,
ttl: "",
keyUsages: Object.fromEntries((cert.keyUsages || []).map((name) => [name, true])),
@@ -152,9 +150,8 @@ export const CertificateModal = ({ popUp, handlePopUpToggle }: Props) => {
} else {
reset({
caId: "",
friendlyName: "",
commonName: "",
altNames: "",
subjectAltNames: "",
ttl: "",
certificateTemplateId: CERT_TEMPLATE_NONE_VALUE,
keyUsages: {
@@ -182,10 +179,9 @@ export const CertificateModal = ({ popUp, handlePopUpToggle }: Props) => {
const onFormSubmit = async ({
caId,
friendlyName,
collectionId,
commonName,
altNames,
subjectAltNames,
ttl,
keyUsages,
extendedKeyUsages
@@ -198,9 +194,8 @@ export const CertificateModal = ({ popUp, handlePopUpToggle }: Props) => {
certificateTemplateId: selectedCertTemplate ? selectedCertTemplateId : undefined,
projectSlug: currentProject.slug,
pkiCollectionId: collectionId,
friendlyName,
commonName,
altNames,
subjectAltNames,
ttl,
keyUsages: Object.entries(keyUsages)
.filter(([, value]) => value)
@@ -359,20 +354,6 @@ export const CertificateModal = ({ popUp, handlePopUpToggle }: Props) => {
/>
</>
)}
<Controller
control={control}
defaultValue=""
name="friendlyName"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Friendly Name"
isError={Boolean(error)}
errorText={error?.message}
>
<Input {...field} placeholder="My Certificate" isDisabled={Boolean(cert)} />
</FormControl>
)}
/>
<Controller
control={control}
defaultValue=""
@@ -391,7 +372,7 @@ export const CertificateModal = ({ popUp, handlePopUpToggle }: Props) => {
<Controller
control={control}
defaultValue=""
name="altNames"
name="subjectAltNames"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Alternative Names (SANs)"
@@ -7,7 +7,8 @@ import { Button, DeleteActionModal } from "@app/components/v2";
import {
ProjectPermissionPkiSubscriberActions,
ProjectPermissionSub,
useProject
useProject,
useSubscription
} from "@app/context";
import { useDeletePkiSubscriber, useUpdatePkiSubscriber } from "@app/hooks/api";
import { PkiSubscriberStatus } from "@app/hooks/api/pkiSubscriber/types";
@@ -18,9 +19,10 @@ import { PkiSubscribersTable } from "./PkiSubscribersTable";
export const PkiSubscriberSection = () => {
const { currentProject } = useProject();
const { subscription } = useSubscription();
const projectId = currentProject.id;
const allowNewSubscriberCreation = false;
const allowNewSubscriberCreation = subscription.pkiLegacyTemplates;
const { mutateAsync: deletePkiSubscriber } = useDeletePkiSubscriber();
const { mutateAsync: updatePkiSubscriber } = useUpdatePkiSubscriber();
@@ -85,16 +87,16 @@ export const PkiSubscriberSection = () => {
const subscriberName = subscriberStatusData?.subscriberName || "";
return (
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="border-mineshaft-600 bg-mineshaft-900 mb-6 rounded-lg border p-4">
<div className="mb-4 flex justify-between">
<p className="text-xl font-medium text-mineshaft-100">Subscribers</p>
<p className="text-mineshaft-100 text-xl font-medium">Subscribers</p>
<div className="flex w-full justify-end">
<a
target="_blank"
rel="noopener noreferrer"
href="https://infisical.com/docs/documentation/platform/pki/subscribers"
>
<span className="flex w-max cursor-pointer items-center rounded-md border border-mineshaft-500 bg-mineshaft-600 px-4 py-2 text-mineshaft-200 duration-200 hover:border-primary/40 hover:bg-primary/10 hover:text-white">
<span className="border-mineshaft-500 bg-mineshaft-600 text-mineshaft-200 hover:border-primary/40 hover:bg-primary/10 flex w-max cursor-pointer items-center rounded-md border px-4 py-2 duration-200 hover:text-white">
Documentation{" "}
<FontAwesomeIcon
icon={faArrowUpRightFromSquare}
@@ -112,7 +112,9 @@ export const PkiTemplateListPage = () => {
/>
</div>
<div className="container mx-auto mb-6 max-w-7xl rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="mb-4 flex justify-between">
{
subscription?.pkiLegacyTemplates && (
<div className="mb-4 flex justify-between">
<p className="text-xl font-medium text-mineshaft-100">Templates</p>
<div className="flex w-full justify-end">
<ProjectPermissionCan
@@ -134,6 +136,8 @@ export const PkiTemplateListPage = () => {
</ProjectPermissionCan>
</div>
</div>
)
}
<TableContainer>
<Table>
<THead>
@@ -2,6 +2,7 @@ import { useState } from "react";
import { faPlus } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { createNotification } from "@app/components/notifications";
import { Button, DeleteActionModal } from "@app/components/v2";
import { useProjectPermission } from "@app/context";
import {
@@ -54,6 +55,10 @@ export const CertificateProfilesTab = () => {
});
setIsDeleteModalOpen(false);
setSelectedProfile(null);
createNotification({
text: `Certificate profile "${selectedProfile.slug}" deleted successfully`,
type: "success"
});
} catch (error) {
console.error("Failed to delete profile:", error);
}
@@ -122,7 +122,7 @@ export const CreateProfileModal = ({ isOpen, onClose, profile, mode = "create" }
const certificateAuthorities = caData || [];
const certificateTemplates = templateData?.certificateTemplates || [];
const { control, handleSubmit, reset, watch, setValue } = useForm<FormData>({
const { control, handleSubmit, reset, watch, setValue, formState } = useForm<FormData>({
resolver: zodResolver(isEdit ? editSchema : createSchema),
defaultValues: isEdit
? {
@@ -260,7 +260,7 @@ export const CreateProfileModal = ({ isOpen, onClose, profile, mode = "create" }
name="certificateAuthorityId"
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
<FormControl
label="Certificate Authority"
label="Issuing CA"
isRequired
isError={Boolean(error)}
errorText={error?.message}
@@ -296,7 +296,6 @@ export const CreateProfileModal = ({ isOpen, onClose, profile, mode = "create" }
<Select
{...field}
onValueChange={(value) => {
onChange(value);
if (watchedEnrollmentType === "est") {
setValue("estConfig", {
disableBootstrapCaValidation: false,
@@ -311,6 +310,7 @@ export const CreateProfileModal = ({ isOpen, onClose, profile, mode = "create" }
});
setValue("estConfig", undefined);
}
onChange(value);
}}
placeholder="Select a certificate template"
className="w-full"
@@ -339,7 +339,23 @@ export const CreateProfileModal = ({ isOpen, onClose, profile, mode = "create" }
>
<Select
{...field}
onValueChange={onChange}
onValueChange={(value) => {
if (value === "est") {
setValue("apiConfig", undefined);
setValue("estConfig", {
disableBootstrapCaValidation: false,
passphrase: "",
caChain: ""
});
} else {
setValue("estConfig", undefined);
setValue("apiConfig", {
autoRenew: false,
autoRenewDays: 30
});
}
onChange(value);
}}
className="w-full"
position="popper"
isDisabled={Boolean(isEdit)}
@@ -360,17 +376,17 @@ export const CreateProfileModal = ({ isOpen, onClose, profile, mode = "create" }
name="estConfig.disableBootstrapCaValidation"
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl isError={Boolean(error)} errorText={error?.message}>
<div className="flex items-center gap-3 rounded-md border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="border-mineshaft-600 bg-mineshaft-900 flex items-center gap-3 rounded-md border p-4">
<Checkbox
id="disableBootstrapCaValidation"
isChecked={value}
onCheckedChange={onChange}
/>
<div className="space-y-1">
<span className="text-sm font-medium text-mineshaft-100">
<span className="text-mineshaft-100 text-sm font-medium">
Disable Bootstrap CA Validation
</span>
<p className="text-xs text-bunker-300">
<p className="text-bunker-300 text-xs">
Skip CA certificate validation during EST bootstrap phase
</p>
</div>
@@ -417,7 +433,7 @@ export const CreateProfileModal = ({ isOpen, onClose, profile, mode = "create" }
rows={6}
className="w-full font-mono text-xs"
/>
<p className="text-xs text-bunker-400">
<p className="text-bunker-400 text-xs">
Paste the complete CA certificate chain in PEM format
</p>
</div>
@@ -478,6 +494,9 @@ export const CreateProfileModal = ({ isOpen, onClose, profile, mode = "create" }
type="submit"
colorSchema="primary"
isLoading={isEdit ? updateProfile.isPending : createProfile.isPending}
isDisabled={
!formState.isValid || (isEdit ? updateProfile.isPending : createProfile.isPending)
}
>
{isEdit ? "Save Changes" : "Create"}
</Button>
@@ -4,6 +4,7 @@ import {
TableContainer,
TableSkeleton,
TBody,
Td,
Th,
THead,
Tr
@@ -33,14 +34,6 @@ export const ProfileList = ({ onEditProfile, onDeleteProfile }: Props) => {
const profiles = data?.certificateProfiles || [];
if (isLoading) {
return <TableSkeleton columns={6} innerKey="certificate-profiles" />;
}
if (!profiles || profiles.length === 0) {
return <EmptyState title="No Certificate Profiles" />;
}
return (
<TableContainer>
<Table>
@@ -48,21 +41,32 @@ export const ProfileList = ({ onEditProfile, onDeleteProfile }: Props) => {
<Tr>
<Th>Name</Th>
<Th>Enrollment Type</Th>
<Th>Certificate Authority</Th>
<Th>Template</Th>
<Th>Issuing CA</Th>
<Th>Certificate Template</Th>
<Th>Certificates</Th>
<Th className="w-5" />
</Tr>
</THead>
<TBody>
{profiles.map((profile) => (
<ProfileRow
key={profile.id}
profile={profile}
onEditProfile={onEditProfile}
onDeleteProfile={onDeleteProfile}
/>
))}
{isLoading && <TableSkeleton columns={6} innerKey="certificate-profiles" />}
{!isLoading && (!profiles || profiles.length === 0) && (
<Tr>
<Td colSpan={6}>
<EmptyState title="No Certificate Profiles" />
</Td>
</Tr>
)}
{!isLoading &&
profiles &&
profiles.length > 0 &&
profiles.map((profile) => (
<ProfileRow
key={profile.id}
profile={profile}
onEditProfile={onEditProfile}
onDeleteProfile={onDeleteProfile}
/>
))}
</TBody>
</Table>
</TableContainer>
@@ -1,5 +1,5 @@
/* eslint-disable no-nested-ternary */
import { faCircleInfo, faEdit, faEllipsis, faTrash } from "@fortawesome/free-solid-svg-icons";
import { faCheck, faCircleInfo, faCopy, faEdit, faEllipsis, faPlus, faTrash } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
@@ -15,11 +15,16 @@ import {
import { useProjectPermission } from "@app/context";
import {
ProjectPermissionActions,
ProjectPermissionCertificateProfileActions,
ProjectPermissionSub
} from "@app/context/ProjectPermissionContext/types";
import { useGetCaById } from "@app/hooks/api/ca/queries";
import { TCertificateProfile } from "@app/hooks/api/certificateProfiles";
import { useGetCertificateTemplateV2ById } from "@app/hooks/api/certificateTemplates/queries";
import { usePopUp, useToggle } from "@app/hooks";
import { createNotification } from "@app/components/notifications";
import { useCallback } from "react";
import { CertificateIssuanceModal } from "@app/pages/cert-manager/CertificatesPage/components/CertificateIssuanceModal";
interface Props {
profile: TCertificateProfile;
@@ -32,6 +37,27 @@ export const ProfileRow = ({ profile, onEditProfile, onDeleteProfile }: Props) =
const { data: caData } = useGetCaById(profile.caId);
const { popUp, handlePopUpToggle } = usePopUp([
"certificateIssuance"
] as const);
const [isIdCopied, setIsIdCopied] = useToggle(false);
const handleCopyId = useCallback(() => {
setIsIdCopied.on();
navigator.clipboard.writeText(profile.id);
createNotification({
text: "Profile ID copied to clipboard",
type: "info"
});
const timer = setTimeout(() => setIsIdCopied.off(), 2000);
// eslint-disable-next-line consistent-return
return () => clearTimeout(timer);
}, [isIdCopied]);
const { data: templateData } = useGetCertificateTemplateV2ById({
templateId: profile.certificateTemplateId
});
@@ -41,6 +67,11 @@ export const ProfileRow = ({ profile, onEditProfile, onDeleteProfile }: Props) =
ProjectPermissionSub.CertificateAuthorities
);
const canIssueCertificate = permission.can(
ProjectPermissionCertificateProfileActions.IssueCert,
ProjectPermissionSub.CertificateProfiles
);
const canDeleteProfile = permission.can(
ProjectPermissionActions.Delete,
ProjectPermissionSub.CertificateAuthorities
@@ -128,6 +159,12 @@ export const ProfileRow = ({ profile, onEditProfile, onDeleteProfile }: Props) =
</div>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="p-1">
<DropdownMenuItem
icon={<FontAwesomeIcon icon={isIdCopied ? faCheck : faCopy} />}
onClick={() => handleCopyId()}
>
Copy Profile ID
</DropdownMenuItem>
{canEditProfile && (
<DropdownMenuItem
onClick={(e) => {
@@ -139,6 +176,19 @@ export const ProfileRow = ({ profile, onEditProfile, onDeleteProfile }: Props) =
Edit Profile
</DropdownMenuItem>
)}
{
canIssueCertificate && (
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
handlePopUpToggle("certificateIssuance");
}}
icon={<FontAwesomeIcon icon={faPlus} />}
>
Issue Certificate
</DropdownMenuItem>
)
}
{canDeleteProfile && (
<DropdownMenuItem
onClick={(e) => {
@@ -152,6 +202,7 @@ export const ProfileRow = ({ profile, onEditProfile, onDeleteProfile }: Props) =
)}
</DropdownMenuContent>
</DropdownMenu>
<CertificateIssuanceModal popUp={popUp} handlePopUpToggle={handlePopUpToggle} profileId={profile.id}/>
</Td>
</Tr>
);
@@ -2,6 +2,7 @@ import { useState } from "react";
import { faPlus } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { createNotification } from "@app/components/notifications";
import { Button, DeleteActionModal } from "@app/components/v2";
import { useProjectPermission } from "@app/context";
import {
@@ -52,7 +53,11 @@ export const CertificateTemplatesV2Tab = () => {
});
setIsDeleteModalOpen(false);
setSelectedTemplate(null);
} catch (error) {
createNotification({
text: `Certificate template "${selectedTemplate.slug}" deleted successfully`,
type: "success"
});
} catch (error: any) {
console.error("Failed to delete template:", error);
}
};
@@ -1,5 +1,6 @@
import { useMemo } from "react";
import { Controller, useForm } from "react-hook-form";
import { faPlus, faTrash } from "@fortawesome/free-solid-svg-icons";
import { faExclamationTriangle, faPlus, faTrash } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { zodResolver } from "@hookform/resolvers/zod";
@@ -18,7 +19,8 @@ import {
ModalContent,
Select,
SelectItem,
TextArea
TextArea,
Tooltip
} from "@app/components/v2";
import { useProject } from "@app/context";
import {
@@ -27,7 +29,7 @@ import {
} from "@app/hooks/api/certificateTemplates/mutations";
import { TCertificateTemplateV2New } from "@app/hooks/api/certificateTemplates/types";
import { INCLUDE_OPTIONS, SAN_TYPES, SUBJECT_ATTRIBUTE_TYPES } from "./shared/utils";
import { INCLUDE_TYPE_OPTIONS, SAN_TYPE_OPTIONS, SUBJECT_ATTRIBUTE_TYPE_OPTIONS } from "./shared/certificate-constants";
import { KeyUsagesSection, TemplateFormData, templateSchema } from "./shared";
export type FormData = TemplateFormData;
@@ -39,26 +41,18 @@ interface Props {
mode?: "create" | "edit";
}
const ATTRIBUTE_TYPE_LABELS: Record<(typeof SUBJECT_ATTRIBUTE_TYPES)[number], string> = {
common_name: "Common Name (CN)",
organization_name: "Organization (O)",
organization_unit: "Organizational Unit (OU)",
locality: "Locality (L)",
state: "State/Province (ST)",
country: "Country (C)",
email: "Email Address",
street_address: "Street Address",
postal_code: "Postal Code"
const ATTRIBUTE_TYPE_LABELS: Record<(typeof SUBJECT_ATTRIBUTE_TYPE_OPTIONS)[number], string> = {
common_name: "Common Name (CN)"
};
const SAN_TYPE_LABELS: Record<(typeof SAN_TYPES)[number], string> = {
const SAN_TYPE_LABELS: Record<(typeof SAN_TYPE_OPTIONS)[number], string> = {
dns_name: "DNS Name",
ip_address: "IP Address",
email: "Email",
uri: "URI"
};
const INCLUDE_TYPE_LABELS: Record<(typeof INCLUDE_OPTIONS)[number], string> = {
const INCLUDE_TYPE_LABELS: Record<(typeof INCLUDE_TYPE_OPTIONS)[number], string> = {
mandatory: "Mandatory",
optional: "Optional",
prohibit: "Prohibited"
@@ -89,7 +83,36 @@ export const CreateTemplateModal = ({ isOpen, onClose, template, mode = "create"
const isEdit = mode === "edit" && template;
const { control, handleSubmit, reset, watch, setValue } = useForm<FormData>({
const validateAttributeRules = (attributes: FormData["attributes"]) => {
if (!attributes) return { isValid: true, warnings: [], invalidIndices: [] };
const warnings: string[] = [];
const invalidIndices: number[] = [];
const attributesByType = attributes.reduce((acc, attr, index) => {
if (!acc[attr.type]) acc[attr.type] = [];
acc[attr.type].push({ ...attr, index });
return acc;
}, {} as Record<string, Array<(typeof attributes[0] & { index: number })>>);
Object.entries(attributesByType).forEach(([type, attrs]) => {
const mandatoryAttrs = attrs.filter(attr => attr.include === 'mandatory');
if (mandatoryAttrs.length > 1) {
mandatoryAttrs.forEach(attr => invalidIndices.push(attr.index));
warnings.push(`Multiple mandatory values found for ${ATTRIBUTE_TYPE_LABELS[type as keyof typeof ATTRIBUTE_TYPE_LABELS]}. Only one mandatory value is allowed per attribute type.`);
}
if (mandatoryAttrs.length === 1 && attrs.length > 1) {
attrs.forEach(attr => invalidIndices.push(attr.index));
warnings.push(`When a mandatory value exists for ${ATTRIBUTE_TYPE_LABELS[type as keyof typeof ATTRIBUTE_TYPE_LABELS]}, no other values (optional or forbidden) are allowed for that attribute type.`);
}
});
return { isValid: warnings.length === 0, warnings, invalidIndices };
};
const { control, handleSubmit, reset, watch, setValue, formState } = useForm<FormData>({
resolver: zodResolver(templateSchema),
defaultValues: isEdit
? {
@@ -138,13 +161,42 @@ export const CreateTemplateModal = ({ isOpen, onClose, template, mode = "create"
const watchedAttributes = watch("attributes") || [];
const watchedSans = watch("subjectAlternativeNames") || [];
const watchedKeyUsages = watch("keyUsages");
const watchedExtendedKeyUsages = watch("extendedKeyUsages");
const watchedKeyUsages = watch("keyUsages") || { requiredUsages: [], optionalUsages: [] };
const watchedExtendedKeyUsages = watch("extendedKeyUsages") || { requiredUsages: [], optionalUsages: [] };
const attributeValidation = useMemo(() =>
validateAttributeRules(watchedAttributes),
[watchedAttributes]
);
const onFormSubmit = async (data: FormData) => {
try {
if (!currentProject?.id && !isEdit) return;
if (!attributeValidation.isValid) {
createNotification({
text: "Please fix validation errors before submitting",
type: "error"
});
return;
}
const hasEmptyAttributeValues = data.attributes?.some(attr =>
!attr.value || attr.value.length === 0 || attr.value.some(v => !v.trim())
);
const hasEmptySanValues = data.subjectAlternativeNames?.some(san =>
!san.value || san.value.length === 0 || san.value.some(v => !v.trim())
);
if (hasEmptyAttributeValues || hasEmptySanValues) {
createNotification({
text: "All values must be non-empty. Use wildcards (*) if needed.",
type: "error"
});
return;
}
if (isEdit) {
const updateData = {
templateId: template.id,
@@ -221,9 +273,9 @@ export const CreateTemplateModal = ({ isOpen, onClose, template, mode = "create"
const addAttribute = () => {
const newAttribute = {
type: SUBJECT_ATTRIBUTE_TYPES[0],
include: INCLUDE_OPTIONS[1],
value: []
type: SUBJECT_ATTRIBUTE_TYPE_OPTIONS[0],
include: INCLUDE_TYPE_OPTIONS[1],
value: ["*"]
};
setValue("attributes", [...watchedAttributes, newAttribute]);
};
@@ -235,9 +287,9 @@ export const CreateTemplateModal = ({ isOpen, onClose, template, mode = "create"
const addSan = () => {
const newSan = {
type: SAN_TYPES[0],
include: INCLUDE_OPTIONS[1],
value: []
type: SAN_TYPE_OPTIONS[0],
include: INCLUDE_TYPE_OPTIONS[1],
value: ["*"]
};
setValue("subjectAlternativeNames", [...watchedSans, newSan]);
};
@@ -247,42 +299,18 @@ export const CreateTemplateModal = ({ isOpen, onClose, template, mode = "create"
setValue("subjectAlternativeNames", newSans);
};
const toggleKeyUsage = (usage: string, type: "required" | "optional") => {
const current = watchedKeyUsages || { requiredUsages: [], optionalUsages: [] };
const otherType = type === "required" ? "optional" : "required";
const currentList = Array.isArray(current[`${type}Usages`]) ? current[`${type}Usages`] : [];
const otherList = Array.isArray(current[`${otherType}Usages`])
? current[`${otherType}Usages`]
: [];
const newOtherList = (otherList || []).filter((u) => u !== usage);
const newCurrentList = currentList?.includes(usage)
? currentList.filter((u) => u !== usage)
: [...(currentList || []), usage];
const handleKeyUsagesChange = (usages: { requiredUsages: string[]; optionalUsages: string[] }) => {
setValue("keyUsages", {
[`${type}Usages`]: newCurrentList,
[`${otherType}Usages`]: newOtherList
} as any);
requiredUsages: usages.requiredUsages as any,
optionalUsages: usages.optionalUsages as any
});
};
const toggleExtendedKeyUsage = (usage: string, type: "required" | "optional") => {
const current = watchedExtendedKeyUsages || { requiredUsages: [], optionalUsages: [] };
const otherType = type === "required" ? "optional" : "required";
const currentList = Array.isArray(current[`${type}Usages`]) ? current[`${type}Usages`] : [];
const otherList = Array.isArray(current[`${otherType}Usages`])
? current[`${otherType}Usages`]
: [];
const newOtherList = (otherList || []).filter((u) => u !== usage);
const newCurrentList = currentList?.includes(usage)
? currentList.filter((u) => u !== usage)
: [...(currentList || []), usage];
const handleExtendedKeyUsagesChange = (usages: { requiredUsages: string[]; optionalUsages: string[] }) => {
setValue("extendedKeyUsages", {
[`${type}Usages`]: newCurrentList,
[`${otherType}Usages`]: newOtherList
} as any);
requiredUsages: usages.requiredUsages as any,
optionalUsages: usages.optionalUsages as any
});
};
return (
@@ -297,7 +325,7 @@ export const CreateTemplateModal = ({ isOpen, onClose, template, mode = "create"
>
<ModalContent
className="max-w-4xl"
title={isEdit ? "Edit Certificate Template V2" : "Create Certificate Template V2"}
title={isEdit ? "Edit Certificate Template" : "Create Certificate Template"}
subTitle={
isEdit
? `Update configuration for ${template?.slug}`
@@ -306,42 +334,36 @@ export const CreateTemplateModal = ({ isOpen, onClose, template, mode = "create"
>
<form onSubmit={handleSubmit(onFormSubmit)} className="space-y-6">
<Accordion type="multiple" defaultValue={["basic"]} className="w-full">
<AccordionItem value="basic">
<AccordionTrigger>Basic Information</AccordionTrigger>
<AccordionContent>
<div className="space-y-4">
<Controller
control={control}
name="slug"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Template Name"
isRequired
isError={Boolean(error)}
errorText={error?.message}
>
<Input {...field} placeholder="Enter template name" className="w-full" />
</FormControl>
)}
/>
<Controller
control={control}
name="description"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Description"
isError={Boolean(error)}
errorText={error?.message}
>
<TextArea {...field} placeholder="Enter template description" rows={3} />
</FormControl>
)}
/>
</div>
</AccordionContent>
</AccordionItem>
<div className="space-y-4">
<Controller
control={control}
name="slug"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Template Name"
isRequired
isError={Boolean(error)}
errorText={error?.message}
>
<Input {...field} placeholder="Enter template name" className="w-full" />
</FormControl>
)}
/>
<Controller
control={control}
name="description"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Description"
isError={Boolean(error)}
errorText={error?.message}
>
<TextArea {...field} placeholder="Enter template description" rows={3} />
</FormControl>
)}
/>
</div>
<AccordionItem value="attributes">
<AccordionTrigger>Subject Attributes</AccordionTrigger>
<AccordionContent>
@@ -351,92 +373,173 @@ export const CreateTemplateModal = ({ isOpen, onClose, template, mode = "create"
type="button"
onClick={addAttribute}
size="sm"
variant="outline_bg"
leftIcon={<FontAwesomeIcon icon={faPlus} />}
>
Add Attribute
</Button>
</div>
{/* Validation warnings */}
{!attributeValidation.isValid && attributeValidation.warnings.length > 0 && (
<div className="bg-yellow-500/10 border border-yellow-500/20 rounded-md p-3">
<div className="flex items-start gap-2">
<FontAwesomeIcon icon={faExclamationTriangle} className="text-yellow-500 mt-0.5" />
<div className="flex-1">
<h4 className="text-yellow-500 font-medium text-sm">Validation Warnings</h4>
<ul className="text-yellow-400 text-sm mt-1 space-y-1">
{attributeValidation.warnings.map((warning, index) => (
<li key={index}>• {warning}</li>
))}
</ul>
</div>
</div>
</div>
)}
<div className="space-y-2">
{watchedAttributes.length === 0 ? (
<div className="py-8 text-center text-bunker-300">
<div className="text-bunker-300 py-8 text-center">
No subject attributes configured yet. Click &quot;Add Attribute&quot; to get
started.
</div>
) : (
watchedAttributes.map((attr, index) => (
<div
key={`attr-${attr.type}`}
className="flex flex-col space-y-2 rounded-md border border-mineshaft-600 p-4"
>
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-mineshaft-200">
{ATTRIBUTE_TYPE_LABELS[attr.type] || attr.type}
</span>
</div>
watchedAttributes.map((attr, index) => {
const isInvalid = attributeValidation.invalidIndices.includes(index);
const errorClass = isInvalid ? "border-red-500 focus:border-red-500" : "";
<div className="flex gap-3">
<Select
value={attr.type}
onValueChange={(value) => {
const newAttributes = [...watchedAttributes];
newAttributes[index] = { ...attr, type: value as any };
setValue("attributes", newAttributes);
}}
position="popper"
>
{SUBJECT_ATTRIBUTE_TYPES.map((type) => (
<SelectItem key={type} value={type}>
{ATTRIBUTE_TYPE_LABELS[type]}
</SelectItem>
))}
</Select>
return (
<div key={`attr-${attr.type}-${attr.include}-${index}`} className="flex items-start gap-2">
{isInvalid ? (
<Tooltip content="This attribute has validation errors. Check the warnings above for details.">
<div className="flex items-start gap-2 flex-1">
<Select
value={attr.type}
onValueChange={(value) => {
const newAttributes = [...watchedAttributes];
newAttributes[index] = { ...attr, type: value as any };
setValue("attributes", newAttributes);
}}
className={`w-48 ${errorClass}`}
>
{SUBJECT_ATTRIBUTE_TYPE_OPTIONS.map((type) => (
<SelectItem key={type} value={type}>
{ATTRIBUTE_TYPE_LABELS[type]}
</SelectItem>
))}
</Select>
<Select
value={attr.include}
onValueChange={(value) => {
const newAttributes = [...watchedAttributes];
newAttributes[index] = { ...attr, include: value as any };
setValue("attributes", newAttributes);
}}
position="popper"
>
{INCLUDE_OPTIONS.map((type) => (
<SelectItem key={type} value={type}>
{INCLUDE_TYPE_LABELS[type]}
</SelectItem>
))}
</Select>
<Select
value={attr.include}
onValueChange={(value) => {
const newAttributes = [...watchedAttributes];
newAttributes[index] = { ...attr, include: value as any };
setValue("attributes", newAttributes);
}}
className={`w-32 ${errorClass}`}
>
{INCLUDE_TYPE_OPTIONS.map((type) => (
<SelectItem key={type} value={type}>
{INCLUDE_TYPE_LABELS[type]}
</SelectItem>
))}
</Select>
<Input
placeholder="Pattern/Value (optional)"
value={attr.value?.[0] || ""}
onChange={(e) => {
const newAttributes = [...watchedAttributes];
newAttributes[index] = {
...attr,
value: e.target.value ? [e.target.value] : []
};
setValue("attributes", newAttributes);
}}
/>
<Input
placeholder="Pattern/Value (required - use * for wildcards)"
value={attr.value?.[0] || ""}
onChange={(e) => {
const newAttributes = [...watchedAttributes];
newAttributes[index] = {
...attr,
value: e.target.value.trim() ? [e.target.value.trim()] : []
};
setValue("attributes", newAttributes);
}}
className={`flex-1 ${errorClass} ${
attr.value && attr.value.length > 0 && attr.value[0] === ""
? "border-red-500 focus:border-red-500"
: ""
}`}
required
/>
</div>
</Tooltip>
) : (
<>
<Select
value={attr.type}
onValueChange={(value) => {
const newAttributes = [...watchedAttributes];
newAttributes[index] = { ...attr, type: value as any };
setValue("attributes", newAttributes);
}}
className="w-48"
>
{SUBJECT_ATTRIBUTE_TYPE_OPTIONS.map((type) => (
<SelectItem key={type} value={type}>
{ATTRIBUTE_TYPE_LABELS[type]}
</SelectItem>
))}
</Select>
<Select
value={attr.include}
onValueChange={(value) => {
const newAttributes = [...watchedAttributes];
newAttributes[index] = { ...attr, include: value as any };
setValue("attributes", newAttributes);
}}
className="w-32"
>
{INCLUDE_TYPE_OPTIONS.map((type) => (
<SelectItem key={type} value={type}>
{INCLUDE_TYPE_LABELS[type]}
</SelectItem>
))}
</Select>
<Input
placeholder="Pattern/Value (required - use * for wildcards)"
value={attr.value?.[0] || ""}
onChange={(e) => {
const newAttributes = [...watchedAttributes];
newAttributes[index] = {
...attr,
value: e.target.value.trim() ? [e.target.value.trim()] : []
};
setValue("attributes", newAttributes);
}}
className={`flex-1 ${
attr.value && attr.value.length > 0 && attr.value[0] === ""
? "border-red-500 focus:border-red-500"
: ""
}`}
required
/>
</>
)}
{watchedAttributes.length > 1 && (
<IconButton
ariaLabel="delete attribute"
ariaLabel="Remove Attribute"
variant="plain"
size="sm"
onClick={() => removeAttribute(index)}
>
<FontAwesomeIcon icon={faTrash} className="text-red-500" />
<FontAwesomeIcon icon={faTrash} />
</IconButton>
</div>
)}
</div>
))
);
})
)}
</div>
</div>
</AccordionContent>
</AccordionItem>
<AccordionItem value="san">
<AccordionItem value="san" className="mt-4">
<AccordionTrigger>Subject Alternative Names</AccordionTrigger>
<AccordionContent>
<div className="space-y-4">
@@ -445,6 +548,7 @@ export const CreateTemplateModal = ({ isOpen, onClose, template, mode = "create"
type="button"
onClick={addSan}
size="sm"
variant="outline_bg"
leftIcon={<FontAwesomeIcon icon={faPlus} />}
>
Add SAN
@@ -453,72 +557,72 @@ export const CreateTemplateModal = ({ isOpen, onClose, template, mode = "create"
<div className="space-y-2">
{watchedSans.length === 0 ? (
<div className="py-8 text-center text-bunker-300">
<div className="text-bunker-300 py-8 text-center">
No subject alternative names configured yet. Click &quot;Add SAN&quot; to
get started.
</div>
) : (
watchedSans.map((san, index) => (
<div
key={`san-${san.type}`}
className="flex flex-col space-y-4 rounded-md border border-mineshaft-600 p-4"
>
<div className="flex gap-3">
<Select
value={san.type}
onValueChange={(value) => {
const newSans = [...watchedSans];
newSans[index] = { ...san, type: value as any };
setValue("subjectAlternativeNames", newSans);
}}
position="popper"
>
{SAN_TYPES.map((type) => (
<SelectItem key={type} value={type}>
{SAN_TYPE_LABELS[type]}
</SelectItem>
))}
</Select>
<div key={`san-${san.type}-${san.include}-${index}`} className="flex items-start gap-2">
<Select
value={san.type}
onValueChange={(value) => {
const newSans = [...watchedSans];
newSans[index] = { ...san, type: value as any };
setValue("subjectAlternativeNames", newSans);
}}
className="w-24"
>
{SAN_TYPE_OPTIONS.map((type) => (
<SelectItem key={type} value={type}>
{SAN_TYPE_LABELS[type]}
</SelectItem>
))}
</Select>
<Select
value={san.include}
onValueChange={(value) => {
const newSans = [...watchedSans];
newSans[index] = { ...san, include: value as any };
setValue("subjectAlternativeNames", newSans);
}}
position="popper"
>
{INCLUDE_OPTIONS.map((type) => (
<SelectItem key={type} value={type}>
{INCLUDE_TYPE_LABELS[type]}
</SelectItem>
))}
</Select>
<Select
value={san.include}
onValueChange={(value) => {
const newSans = [...watchedSans];
newSans[index] = { ...san, include: value as any };
setValue("subjectAlternativeNames", newSans);
}}
className="w-32"
>
{INCLUDE_TYPE_OPTIONS.map((type) => (
<SelectItem key={type} value={type}>
{INCLUDE_TYPE_LABELS[type]}
</SelectItem>
))}
</Select>
<Input
placeholder="Pattern/Value (optional)"
value={san.value?.[0] || ""}
onChange={(e) => {
const newSans = [...watchedSans];
newSans[index] = {
...san,
value: e.target.value ? [e.target.value] : []
};
setValue("subjectAlternativeNames", newSans);
}}
/>
<div className="flex items-center justify-between">
<IconButton
onClick={() => removeSan(index)}
size="sm"
variant="plain"
ariaLabel="Remove SAN"
>
<FontAwesomeIcon icon={faTrash} className="text-red-500" />
</IconButton>
</div>
</div>
<Input
placeholder="Pattern/Value (required - use * for wildcards)"
value={san.value?.[0] || ""}
onChange={(e) => {
const newSans = [...watchedSans];
newSans[index] = {
...san,
value: e.target.value.trim() ? [e.target.value.trim()] : []
};
setValue("subjectAlternativeNames", newSans);
}}
className={`flex-1 ${
san.value && san.value.length > 0 && san.value[0] === ""
? "border-red-500 focus:border-red-500"
: ""
}`}
required
/>
<IconButton
ariaLabel="Remove SAN"
variant="plain"
size="sm"
onClick={() => removeSan(index)}
>
<FontAwesomeIcon icon={faTrash} />
</IconButton>
</div>
))
)}
@@ -527,19 +631,19 @@ export const CreateTemplateModal = ({ isOpen, onClose, template, mode = "create"
</AccordionContent>
</AccordionItem>
<AccordionItem value="usages">
<AccordionItem value="usages" className="mt-4">
<AccordionTrigger>Key Usages</AccordionTrigger>
<AccordionContent>
<KeyUsagesSection
watchedKeyUsages={watchedKeyUsages}
watchedExtendedKeyUsages={watchedExtendedKeyUsages}
toggleKeyUsage={toggleKeyUsage}
toggleExtendedKeyUsage={toggleExtendedKeyUsage}
onKeyUsagesChange={handleKeyUsagesChange}
onExtendedKeyUsagesChange={handleExtendedKeyUsagesChange}
/>
</AccordionContent>
</AccordionItem>
<AccordionItem value="constraints">
<AccordionItem value="constraints" className="mt-4">
<AccordionTrigger>Constraints</AccordionTrigger>
<AccordionContent>
<div className="space-y-4">
@@ -637,7 +741,7 @@ export const CreateTemplateModal = ({ isOpen, onClose, template, mode = "create"
/>
<label
htmlFor={`sig-alg-${alg}`}
className="cursor-pointer text-sm font-medium text-mineshaft-200"
className="text-mineshaft-200 cursor-pointer text-sm font-medium"
>
{alg}
</label>
@@ -717,7 +821,7 @@ export const CreateTemplateModal = ({ isOpen, onClose, template, mode = "create"
/>
<label
htmlFor={`key-alg-${alg}`}
className="cursor-pointer text-sm font-medium text-mineshaft-200"
className="text-mineshaft-200 cursor-pointer text-sm font-medium"
>
{alg}
</label>
@@ -765,6 +869,11 @@ export const CreateTemplateModal = ({ isOpen, onClose, template, mode = "create"
type="submit"
colorSchema="primary"
isLoading={isEdit ? updateTemplate.isPending : createTemplate.isPending}
isDisabled={
!formState.isValid ||
!attributeValidation.isValid ||
(isEdit ? updateTemplate.isPending : createTemplate.isPending)
}
>
{isEdit ? "Save Changes" : "Create"}
</Button>
@@ -56,14 +56,6 @@ export const TemplateList = ({ onEditTemplate, onDeleteTemplate }: Props) => {
return new Date(dateString).toLocaleDateString();
};
if (isLoading) {
return <TableSkeleton columns={4} innerKey="certificate-templates" />;
}
if (!templates || templates.length === 0) {
return <EmptyState title="No Certificate Templates" />;
}
return (
<TableContainer>
<Table>
@@ -75,7 +67,15 @@ export const TemplateList = ({ onEditTemplate, onDeleteTemplate }: Props) => {
</Tr>
</THead>
<TBody>
{templates.map((template) => (
{isLoading && <TableSkeleton columns={3} innerKey="certificate-templates" />}
{!isLoading && (!templates || templates.length === 0) && (
<Tr>
<Td colSpan={3}>
<EmptyState title="No Certificate Templates" />
</Td>
</Tr>
)}
{!isLoading && templates && templates.length > 0 && templates.map((template) => (
<Tr
key={template.id}
className="h-10 transition-colors duration-100 hover:bg-mineshaft-700"
@@ -0,0 +1,164 @@
export enum CertSubjectAlternativeNameType {
DNS_NAME = "dns_name",
IP_ADDRESS = "ip_address",
EMAIL = "email",
URI = "uri"
}
export enum CertKeyUsageType {
DIGITAL_SIGNATURE = "digital_signature",
KEY_ENCIPHERMENT = "key_encipherment",
NON_REPUDIATION = "non_repudiation",
DATA_ENCIPHERMENT = "data_encipherment",
KEY_AGREEMENT = "key_agreement",
KEY_CERT_SIGN = "key_cert_sign",
CRL_SIGN = "crl_sign",
ENCIPHER_ONLY = "encipher_only",
DECIPHER_ONLY = "decipher_only"
}
export enum CertExtendedKeyUsageType {
CLIENT_AUTH = "client_auth",
SERVER_AUTH = "server_auth",
CODE_SIGNING = "code_signing",
EMAIL_PROTECTION = "email_protection",
OCSP_SIGNING = "ocsp_signing",
TIME_STAMPING = "time_stamping"
}
export enum CertIncludeType {
MANDATORY = "mandatory",
OPTIONAL = "optional",
PROHIBIT = "prohibit"
}
export enum CertDurationUnit {
DAYS = "days",
MONTHS = "months",
YEARS = "years"
}
export enum CertSubjectAttributeType {
COMMON_NAME = "common_name"
}
export const formatSANType = (type: CertSubjectAlternativeNameType): string => {
switch (type) {
case CertSubjectAlternativeNameType.DNS_NAME:
return "DNS Name";
case CertSubjectAlternativeNameType.IP_ADDRESS:
return "IP Address";
case CertSubjectAlternativeNameType.EMAIL:
return "Email";
case CertSubjectAlternativeNameType.URI:
return "URI";
default:
return type;
}
};
export const formatKeyUsage = (usage: CertKeyUsageType): string => {
switch (usage) {
case CertKeyUsageType.DIGITAL_SIGNATURE:
return "Digital Signature";
case CertKeyUsageType.KEY_ENCIPHERMENT:
return "Key Encipherment";
case CertKeyUsageType.NON_REPUDIATION:
return "Non Repudiation";
case CertKeyUsageType.DATA_ENCIPHERMENT:
return "Data Encipherment";
case CertKeyUsageType.KEY_AGREEMENT:
return "Key Agreement";
case CertKeyUsageType.KEY_CERT_SIGN:
return "Key Cert Sign";
case CertKeyUsageType.CRL_SIGN:
return "CRL Sign";
case CertKeyUsageType.ENCIPHER_ONLY:
return "Encipher Only";
case CertKeyUsageType.DECIPHER_ONLY:
return "Decipher Only";
default:
return usage;
}
};
export const formatExtendedKeyUsage = (usage: CertExtendedKeyUsageType): string => {
switch (usage) {
case CertExtendedKeyUsageType.CLIENT_AUTH:
return "Client Auth";
case CertExtendedKeyUsageType.SERVER_AUTH:
return "Server Auth";
case CertExtendedKeyUsageType.CODE_SIGNING:
return "Code Signing";
case CertExtendedKeyUsageType.EMAIL_PROTECTION:
return "Email Protection";
case CertExtendedKeyUsageType.OCSP_SIGNING:
return "OCSP Signing";
case CertExtendedKeyUsageType.TIME_STAMPING:
return "Time Stamping";
default:
return usage;
}
};
export const formatSubjectAttributeType = (type: CertSubjectAttributeType): string => {
switch (type) {
case CertSubjectAttributeType.COMMON_NAME:
return "Common Name";
default:
return type;
}
};
export const formatIncludeType = (include: CertIncludeType): string => {
switch (include) {
case CertIncludeType.MANDATORY:
return "Mandatory";
case CertIncludeType.OPTIONAL:
return "Optional";
case CertIncludeType.PROHIBIT:
return "Prohibit";
default:
return include;
}
};
export const mapLegacySANTypeToStandard = (type: string): CertSubjectAlternativeNameType => {
switch (type) {
case "dns":
case "dns_name":
return CertSubjectAlternativeNameType.DNS_NAME;
case "ip":
case "ip_address":
return CertSubjectAlternativeNameType.IP_ADDRESS;
case "email":
return CertSubjectAlternativeNameType.EMAIL;
case "uri":
case "url":
return CertSubjectAlternativeNameType.URI;
default:
throw new Error(`Unknown SAN type: ${type}`);
}
};
export const mapSANTypeToLegacy = (type: CertSubjectAlternativeNameType): string => {
switch (type) {
case CertSubjectAlternativeNameType.DNS_NAME:
return "dns";
case CertSubjectAlternativeNameType.IP_ADDRESS:
return "ip";
case CertSubjectAlternativeNameType.EMAIL:
return "email";
case CertSubjectAlternativeNameType.URI:
return "uri";
default:
return type;
}
};
export const SAN_TYPE_OPTIONS = Object.values(CertSubjectAlternativeNameType);
export const KEY_USAGE_OPTIONS = Object.values(CertKeyUsageType);
export const EXTENDED_KEY_USAGE_OPTIONS = Object.values(CertExtendedKeyUsageType);
export const INCLUDE_TYPE_OPTIONS = Object.values(CertIncludeType);
export const DURATION_UNIT_OPTIONS = Object.values(CertDurationUnit);
export const SUBJECT_ATTRIBUTE_TYPE_OPTIONS = Object.values(CertSubjectAttributeType);
@@ -1,141 +1,152 @@
import { Button } from "@app/components/v2";
import { Checkbox } from "@app/components/v2";
import {
EXTENDED_KEY_USAGES,
formatUsageName,
getUsageState,
KEY_USAGES,
toggleUsageState
} from "./utils";
CertExtendedKeyUsageType,
CertKeyUsageType,
formatExtendedKeyUsage,
formatKeyUsage,
EXTENDED_KEY_USAGE_OPTIONS,
KEY_USAGE_OPTIONS
} from "./certificate-constants";
type UsageToggleProps = {
value: "required" | "optional" | undefined;
onChange: (value: "required" | "optional" | undefined) => void;
type UsageState = "mandatory" | "optional" | undefined;
type ThreeStateCheckboxProps = {
value: UsageState;
onChange: (newValue: UsageState) => void;
label: string;
id: string;
};
export const UsageToggle = ({ value, onChange }: UsageToggleProps) => {
const ThreeStateCheckbox = ({ value, onChange, label, id }: ThreeStateCheckboxProps) => {
const handleClick = () => {
if (value === undefined) {
onChange("optional");
} else if (value === "optional") {
onChange("mandatory");
} else {
onChange(undefined);
}
};
const getCheckboxState = () => {
if (value) return true;
return false;
};
const getIndeterminateState = () => {
return value === "optional";
};
const getStateLabel = () => {
if (value === "mandatory") return " (Mandatory)";
if (value === "optional") return " (Optional)";
return "";
};
return (
<div className="border-mineshaft-600 bg-mineshaft-800 flex gap-x-0.5 rounded-md border p-1">
<Button
variant="outline_bg"
onClick={() => {
onChange(value === "required" ? undefined : "required");
}}
size="xs"
className={`${
value === "required" ? "bg-mineshaft-500" : "bg-transparent"
} hover:bg-mineshaft-600 min-w-[2.4rem] rounded border-none`}
<div className="flex items-center space-x-3">
<Checkbox
id={id}
isChecked={getCheckboxState()}
isIndeterminate={getIndeterminateState()}
onCheckedChange={handleClick}
/>
<label
htmlFor={id}
className="text-mineshaft-200 cursor-pointer text-sm font-medium"
>
Required
</Button>
<Button
variant="outline_bg"
onClick={() => {
onChange(value === "optional" ? undefined : "optional");
}}
size="xs"
className={`${
value === "optional" ? "bg-mineshaft-500" : "bg-transparent"
} hover:bg-mineshaft-600 min-w-[2.4rem] rounded border-none`}
>
Optional
</Button>
{label}
{value && (
<span className="text-mineshaft-400 text-xs ml-1">
{getStateLabel()}
</span>
)}
</label>
</div>
);
};
type KeyUsagesSectionProps = {
watchedKeyUsages?: {
requiredUsages?: string[];
optionalUsages?: string[];
};
watchedExtendedKeyUsages?: {
requiredUsages?: string[];
optionalUsages?: string[];
};
toggleKeyUsage: (usage: string, type: "required" | "optional") => void;
toggleExtendedKeyUsage: (usage: string, type: "required" | "optional") => void;
watchedKeyUsages?: { requiredUsages?: string[]; optionalUsages?: string[] };
watchedExtendedKeyUsages?: { requiredUsages?: string[]; optionalUsages?: string[] };
onKeyUsagesChange: (usages: { requiredUsages: string[]; optionalUsages: string[] }) => void;
onExtendedKeyUsagesChange: (usages: { requiredUsages: string[]; optionalUsages: string[] }) => void;
};
export const KeyUsagesSection = ({
watchedKeyUsages,
watchedExtendedKeyUsages,
toggleKeyUsage,
toggleExtendedKeyUsage
watchedKeyUsages = { requiredUsages: [], optionalUsages: [] },
watchedExtendedKeyUsages = { requiredUsages: [], optionalUsages: [] },
onKeyUsagesChange,
onExtendedKeyUsagesChange
}: KeyUsagesSectionProps) => {
const getUsageState = (usage: string, data: { requiredUsages?: string[]; optionalUsages?: string[] }): UsageState => {
if (data.requiredUsages?.includes(usage)) return "mandatory";
if (data.optionalUsages?.includes(usage)) return "optional";
return undefined;
};
const handleKeyUsageChange = (usage: CertKeyUsageType, newState: UsageState) => {
const currentRequired = watchedKeyUsages.requiredUsages || [];
const currentOptional = watchedKeyUsages.optionalUsages || [];
let newRequired = currentRequired.filter(u => u !== usage);
let newOptional = currentOptional.filter(u => u !== usage);
if (newState === "mandatory") {
newRequired = [...newRequired, usage];
} else if (newState === "optional") {
newOptional = [...newOptional, usage];
}
onKeyUsagesChange({ requiredUsages: newRequired, optionalUsages: newOptional });
};
const handleExtendedKeyUsageChange = (usage: CertExtendedKeyUsageType, newState: UsageState) => {
const currentRequired = watchedExtendedKeyUsages.requiredUsages || [];
const currentOptional = watchedExtendedKeyUsages.optionalUsages || [];
let newRequired = currentRequired.filter(u => u !== usage);
let newOptional = currentOptional.filter(u => u !== usage);
if (newState === "mandatory") {
newRequired = [...newRequired, usage];
} else if (newState === "optional") {
newOptional = [...newOptional, usage];
}
onExtendedKeyUsagesChange({ requiredUsages: newRequired, optionalUsages: newOptional });
};
return (
<div className="space-y-6">
<div className="space-y-3">
<h3 className="text-mineshaft-200 text-sm font-medium">Key Usages</h3>
<div className="grid grid-cols-2 gap-3">
{KEY_USAGES.map((usage) => {
const requiredUsages = Array.isArray(watchedKeyUsages?.requiredUsages)
? watchedKeyUsages.requiredUsages
: [];
const optionalUsages = Array.isArray(watchedKeyUsages?.optionalUsages)
? watchedKeyUsages.optionalUsages
: [];
const currentState = getUsageState(usage, requiredUsages, optionalUsages);
return (
<div key={usage} className="flex items-center justify-between p-2">
<span className="text-mineshaft-300 text-sm capitalize">
{formatUsageName(usage)}
</span>
<UsageToggle
value={currentState}
onChange={(newValue) => {
toggleUsageState(
usage,
newValue,
requiredUsages,
optionalUsages,
(u) => toggleKeyUsage(u, "required"),
(u) => toggleKeyUsage(u, "optional")
);
}}
/>
</div>
);
})}
<div className="grid grid-cols-2 gap-2 pl-2">
{KEY_USAGE_OPTIONS.map((usage) => (
<ThreeStateCheckbox
key={usage}
id={`key-usage-${usage}`}
label={formatKeyUsage(usage)}
value={getUsageState(usage, watchedKeyUsages)}
onChange={(newState) => handleKeyUsageChange(usage, newState)}
/>
))}
</div>
</div>
<div className="space-y-3">
<h3 className="text-mineshaft-200 text-sm font-medium">Extended Key Usages</h3>
<div className="grid grid-cols-2 gap-3">
{EXTENDED_KEY_USAGES.map((usage) => {
const requiredUsages = Array.isArray(watchedExtendedKeyUsages?.requiredUsages)
? watchedExtendedKeyUsages.requiredUsages
: [];
const optionalUsages = Array.isArray(watchedExtendedKeyUsages?.optionalUsages)
? watchedExtendedKeyUsages.optionalUsages
: [];
const currentState = getUsageState(usage, requiredUsages, optionalUsages);
return (
<div key={usage} className="flex items-center justify-between p-2">
<span className="text-mineshaft-300 text-sm capitalize">
{formatUsageName(usage)}
</span>
<UsageToggle
value={currentState}
onChange={(newValue) => {
toggleUsageState(
usage,
newValue,
requiredUsages,
optionalUsages,
(u) => toggleExtendedKeyUsage(u, "required"),
(u) => toggleExtendedKeyUsage(u, "optional")
);
}}
/>
</div>
);
})}
<div className="grid grid-cols-2 gap-2 pl-2">
{EXTENDED_KEY_USAGE_OPTIONS.map((usage) => (
<ThreeStateCheckbox
key={usage}
id={`ext-key-usage-${usage}`}
label={formatExtendedKeyUsage(usage)}
value={getUsageState(usage, watchedExtendedKeyUsages)}
onChange={(newState) => handleExtendedKeyUsageChange(usage, newState)}
/>
))}
</div>
</div>
</div>
@@ -1,33 +1,64 @@
import { z } from "zod";
import { INCLUDE_OPTIONS, SAN_TYPES, SUBJECT_ATTRIBUTE_TYPES } from "./utils";
import {
CertDurationUnit,
CertExtendedKeyUsageType,
CertIncludeType,
CertKeyUsageType,
CertSubjectAlternativeNameType,
CertSubjectAttributeType
} from "./certificate-constants";
export const attributeSchema = z.object({
type: z.enum(SUBJECT_ATTRIBUTE_TYPES),
include: z.enum(INCLUDE_OPTIONS),
value: z.array(z.string()).optional()
type: z.nativeEnum(CertSubjectAttributeType),
include: z.nativeEnum(CertIncludeType),
value: z.array(z.string().min(1, "Value cannot be empty")).optional()
});
export const sanSchema = z.object({
type: z.enum(SAN_TYPES),
include: z.enum(INCLUDE_OPTIONS),
value: z.array(z.string()).optional()
type: z.nativeEnum(CertSubjectAlternativeNameType),
include: z.nativeEnum(CertIncludeType),
value: z.array(z.string().min(1, "Value cannot be empty")).optional()
});
export const templateSchema = z.object({
slug: z.string().trim().min(1, "Template name is required"),
description: z.string().optional(),
attributes: z.array(attributeSchema).optional(),
attributes: z.array(attributeSchema).optional().refine((attributes) => {
if (!attributes) return true;
const attributesByType = attributes.reduce((acc, attr) => {
if (!acc[attr.type]) acc[attr.type] = [];
acc[attr.type].push(attr);
return acc;
}, {} as Record<string, typeof attributes>);
for (const [, attrs] of Object.entries(attributesByType)) {
const mandatoryAttrs = attrs.filter(attr => attr.include === 'mandatory');
if (mandatoryAttrs.length > 1) {
return false;
}
if (mandatoryAttrs.length === 1 && attrs.length > 1) {
return false;
}
}
return true;
}, {
message: "Attribute validation failed: when a mandatory value exists, no other values are allowed for that attribute type"
}),
keyUsages: z
.object({
requiredUsages: z.array(z.string()).optional(),
optionalUsages: z.array(z.string()).optional()
requiredUsages: z.array(z.nativeEnum(CertKeyUsageType)).optional(),
optionalUsages: z.array(z.nativeEnum(CertKeyUsageType)).optional()
})
.optional(),
extendedKeyUsages: z
.object({
requiredUsages: z.array(z.string()).optional(),
optionalUsages: z.array(z.string()).optional()
requiredUsages: z.array(z.nativeEnum(CertExtendedKeyUsageType)).optional(),
optionalUsages: z.array(z.nativeEnum(CertExtendedKeyUsageType)).optional()
})
.optional(),
subjectAlternativeNames: z.array(sanSchema).optional(),
@@ -36,13 +67,13 @@ export const templateSchema = z.object({
maxDuration: z
.object({
value: z.number().min(1, "Duration must be at least 1"),
unit: z.enum(["days", "months", "years"])
unit: z.nativeEnum(CertDurationUnit)
})
.optional(),
minDuration: z
.object({
value: z.number().min(1, "Duration must be at least 1"),
unit: z.enum(["days", "months", "years"])
unit: z.nativeEnum(CertDurationUnit)
})
.optional()
})
@@ -1,47 +1,27 @@
export const KEY_USAGES = [
"digital_signature",
"key_encipherment",
"non_repudiation",
"data_encipherment",
"key_agreement",
"key_cert_sign",
"crl_sign",
"encipher_only",
"decipher_only"
] as const;
export const EXTENDED_KEY_USAGES = [
"client_auth",
"server_auth",
"code_signing",
"email_protection",
"ocsp_signing",
"time_stamping"
] as const;
export const SUBJECT_ATTRIBUTE_TYPES = [
"common_name",
"organization_name",
"organization_unit",
"locality",
"state",
"country",
"email",
"street_address",
"postal_code"
] as const;
export const SAN_TYPES = ["dns_name", "ip_address", "email", "uri"] as const;
export const INCLUDE_OPTIONS = ["mandatory", "optional", "prohibit"] as const;
import {
CertExtendedKeyUsageType,
CertKeyUsageType,
formatExtendedKeyUsage,
formatKeyUsage
} from "./certificate-constants";
export const formatUsageName = (usage: string): string => {
try {
if (Object.values(CertKeyUsageType).includes(usage as CertKeyUsageType)) {
return formatKeyUsage(usage as CertKeyUsageType);
}
if (Object.values(CertExtendedKeyUsageType).includes(usage as CertExtendedKeyUsageType)) {
return formatExtendedKeyUsage(usage as CertExtendedKeyUsageType);
}
} catch {
}
return usage.replace(/_/g, " ");
};
export const getUsageState = (
usage: string,
requiredUsages: string[],
optionalUsages: string[]
usage: CertKeyUsageType | CertExtendedKeyUsageType,
requiredUsages: (CertKeyUsageType | CertExtendedKeyUsageType)[],
optionalUsages: (CertKeyUsageType | CertExtendedKeyUsageType)[]
): "required" | "optional" | undefined => {
if (requiredUsages.includes(usage)) return "required";
if (optionalUsages.includes(usage)) return "optional";
@@ -49,12 +29,12 @@ export const getUsageState = (
};
export const toggleUsageState = (
usage: string,
usage: CertKeyUsageType | CertExtendedKeyUsageType,
newState: "required" | "optional" | undefined,
currentRequiredUsages: string[],
currentOptionalUsages: string[],
toggleRequired: (usage: string) => void,
toggleOptional: (usage: string) => void
currentRequiredUsages: (CertKeyUsageType | CertExtendedKeyUsageType)[],
currentOptionalUsages: (CertKeyUsageType | CertExtendedKeyUsageType)[],
toggleRequired: (usage: CertKeyUsageType | CertExtendedKeyUsageType) => void,
toggleOptional: (usage: CertKeyUsageType | CertExtendedKeyUsageType) => void
) => {
const isRequired = currentRequiredUsages.includes(usage);
const isOptional = currentOptionalUsages.includes(usage);
@@ -7,6 +7,7 @@ import { Tooltip } from "@app/components/v2";
import {
ProjectPermissionActions,
ProjectPermissionCertificateActions,
ProjectPermissionCertificateProfileActions,
ProjectPermissionCmekActions,
ProjectPermissionSub
} from "@app/context";
@@ -215,6 +216,13 @@ const PkiTemplatePolicyActionSchema = z.object({
[ProjectPermissionPkiTemplateActions.IssueCert]: z.boolean().optional(),
[ProjectPermissionPkiTemplateActions.ListCerts]: z.boolean().optional()
});
const CertificateProfilePolicyActionSchema = z.object({
[ProjectPermissionCertificateProfileActions.Read]: z.boolean().optional(),
[ProjectPermissionCertificateProfileActions.Create]: z.boolean().optional(),
[ProjectPermissionCertificateProfileActions.Edit]: z.boolean().optional(),
[ProjectPermissionCertificateProfileActions.Delete]: z.boolean().optional(),
[ProjectPermissionCertificateProfileActions.IssueCert]: z.boolean().optional()
});
const SecretEventsPolicyActionSchema = z.object({
[ProjectPermissionSecretEventActions.SubscribeCreated]: z.boolean().optional(),
@@ -385,6 +393,12 @@ export const projectRoleFormSchema = z.object({
})
.array()
.default([]),
[ProjectPermissionSub.CertificateProfiles]: CertificateProfilePolicyActionSchema.extend({
inverted: z.boolean().optional(),
conditions: ConditionSchema
})
.array()
.default([]),
[ProjectPermissionSub.SshCertificateAuthorities]: GeneralPolicyActionSchema.array().default(
[]
),
@@ -465,6 +479,7 @@ export const isConditionalSubjects = (
subject === ProjectPermissionSub.SecretRotation ||
subject === ProjectPermissionSub.PkiSubscribers ||
subject === ProjectPermissionSub.CertificateTemplates ||
subject === ProjectPermissionSub.CertificateProfiles ||
subject === ProjectPermissionSub.SecretSyncs ||
subject === ProjectPermissionSub.PkiSyncs ||
subject === ProjectPermissionSub.SecretEvents ||
@@ -808,7 +823,9 @@ export const rolePermission2Form = (permissions: TProjectPermission[] = []) => {
const canDelete = action.includes(ProjectPermissionActions.Delete);
const canCreate = action.includes(ProjectPermissionActions.Create);
if (!formVal[subject]) formVal[subject] = [{}];
if (!formVal[subject]) {
formVal[subject] = [{ conditions: [] }];
}
if (canRead) formVal[subject as ProjectPermissionSub.Member]![0].read = true;
if (canEdit) formVal[subject as ProjectPermissionSub.Member]![0].edit = true;
if (canCreate) formVal[subject as ProjectPermissionSub.Member]![0].create = true;
@@ -1138,6 +1155,33 @@ export const rolePermission2Form = (permissions: TProjectPermission[] = []) => {
conditions: conditions ? convertCaslConditionToFormOperator(conditions) : [],
inverted
});
return;
}
if (subject === ProjectPermissionSub.CertificateProfiles) {
if (!formVal[subject]) formVal[subject] = [];
formVal[subject]!.push({
[ProjectPermissionCertificateProfileActions.Edit]: action.includes(
ProjectPermissionCertificateProfileActions.Edit
),
[ProjectPermissionCertificateProfileActions.Delete]: action.includes(
ProjectPermissionCertificateProfileActions.Delete
),
[ProjectPermissionCertificateProfileActions.Create]: action.includes(
ProjectPermissionCertificateProfileActions.Create
),
[ProjectPermissionCertificateProfileActions.Read]: action.includes(
ProjectPermissionCertificateProfileActions.Read
),
[ProjectPermissionCertificateProfileActions.IssueCert]: action.includes(
ProjectPermissionCertificateProfileActions.IssueCert
),
conditions: conditions ? convertCaslConditionToFormOperator(conditions) : [],
inverted
});
return;
}
if (subject === ProjectPermissionSub.PamAccounts) {
@@ -1522,6 +1566,16 @@ export const PROJECT_PERMISSION_OBJECT: TProjectPermissionObject = {
{ label: "List Certificates", value: ProjectPermissionPkiTemplateActions.ListCerts }
]
},
[ProjectPermissionSub.CertificateProfiles]: {
title: "Certificate Profiles",
actions: [
{ label: "Read", value: ProjectPermissionCertificateProfileActions.Read },
{ label: "Create", value: ProjectPermissionCertificateProfileActions.Create },
{ label: "Modify", value: ProjectPermissionCertificateProfileActions.Edit },
{ label: "Remove", value: ProjectPermissionCertificateProfileActions.Delete },
{ label: "Issue Certificates", value: ProjectPermissionCertificateProfileActions.IssueCert }
]
},
[ProjectPermissionSub.SshCertificateAuthorities]: {
title: "SSH Certificate Authorities",
actions: [
@@ -1869,6 +1923,7 @@ const CertificateManagerPermissionSubjects = (enabled = false) => ({
[ProjectPermissionSub.PkiSyncs]: enabled,
[ProjectPermissionSub.CertificateAuthorities]: enabled,
[ProjectPermissionSub.CertificateTemplates]: enabled,
[ProjectPermissionSub.CertificateProfiles]: enabled,
[ProjectPermissionSub.Certificates]: enabled
});