Add CA certs to Chef sync and make field names more dynamic

This commit is contained in:
Carlos Monastyrski
2025-11-19 11:32:12 -03:00
parent cf63266bba
commit 37ff9df999
18 changed files with 441 additions and 74 deletions

View File

@@ -64,7 +64,7 @@ export const CreatePkiSyncModal = ({
"Add Sync"
)
}
className="max-w-2xl"
className="max-w-3xl"
bodyClassName="overflow-visible"
subTitle={
selectedSync ? undefined : "Select a third-party service to sync certificates to."

View File

@@ -15,11 +15,13 @@ type Props = {
export const EditPkiSyncModal = ({ pkiSync, onOpenChange, fields, ...props }: Props) => {
if (!pkiSync) return null;
const modalClassName = fields === PkiSyncEditFields.Mappings ? "max-w-4xl" : "max-w-2xl";
return (
<Modal {...props} onOpenChange={onOpenChange}>
<ModalContent
title={<PkiSyncModalHeader isConfigured destination={pkiSync.destination} />}
className="max-w-2xl"
className={modalClassName}
bodyClassName="overflow-visible"
>
<EditPkiSyncForm onComplete={() => onOpenChange(false)} fields={fields} pkiSync={pkiSync} />

View File

@@ -4,7 +4,6 @@ import { faInfoCircle } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Tab } from "@headlessui/react";
import { zodResolver } from "@hookform/resolvers/zod";
import { twMerge } from "tailwind-merge";
import { createNotification } from "@app/components/notifications";
import { Button, FormControl, Switch } from "@app/components/v2";
@@ -16,6 +15,7 @@ import { PkiSyncFormSchema, TPkiSyncForm } from "./schemas/pki-sync-schema";
import { PkiSyncCertificatesFields } from "./PkiSyncCertificatesFields";
import { PkiSyncDestinationFields } from "./PkiSyncDestinationFields";
import { PkiSyncDetailsFields } from "./PkiSyncDetailsFields";
import { PkiSyncFieldMappingsFields } from "./PkiSyncFieldMappingsFields";
import { PkiSyncOptionsFields } from "./PkiSyncOptionsFields";
import { PkiSyncReviewFields } from "./PkiSyncReviewFields";
@@ -26,13 +26,38 @@ type Props = {
initialData?: any;
};
const FORM_TABS: { name: string; key: string; fields: (keyof TPkiSyncForm)[] }[] = [
{ name: "Destination", key: "destination", fields: ["connection", "destinationConfig"] },
{ name: "Sync Options", key: "options", fields: ["syncOptions"] },
{ name: "Details", key: "details", fields: ["name", "description"] },
{ name: "Certificates", key: "certificates", fields: ["certificateIds"] },
{ name: "Review", key: "review", fields: [] }
];
const getFormTabs = (
destination: PkiSync
): { name: string; key: string; fields: (keyof TPkiSyncForm)[] }[] => {
const baseTabs = [
{
name: "Destination",
key: "destination",
fields: ["connection", "destinationConfig"] as (keyof TPkiSyncForm)[]
},
{ name: "Sync Options", key: "options", fields: ["syncOptions"] as (keyof TPkiSyncForm)[] }
];
if (destination === PkiSync.Chef) {
baseTabs.push({
name: "Mappings",
key: "mappings",
fields: ["syncOptions"] as (keyof TPkiSyncForm)[]
});
}
baseTabs.push(
{ name: "Details", key: "details", fields: ["name", "description"] as (keyof TPkiSyncForm)[] },
{
name: "Certificates",
key: "certificates",
fields: ["certificateIds"] as (keyof TPkiSyncForm)[]
},
{ name: "Review", key: "review", fields: [] as (keyof TPkiSyncForm)[] }
);
return baseTabs;
};
export const CreatePkiSyncForm = ({ destination, onComplete, onCancel, initialData }: Props) => {
const createPkiSync = useCreatePkiSync();
@@ -42,6 +67,7 @@ export const CreatePkiSyncForm = ({ destination, onComplete, onCancel, initialDa
const [showConfirmation, setShowConfirmation] = useState(false);
const [selectedTabIndex, setSelectedTabIndex] = useState(0);
const FORM_TABS = getFormTabs(destination);
const { syncOption } = usePkiSyncOption(destination);
@@ -55,7 +81,15 @@ export const CreatePkiSyncForm = ({ destination, onComplete, onCancel, initialDa
canImportCertificates: false,
canRemoveCertificates: false,
preserveArn: true,
certificateNameSchema: syncOption?.defaultCertificateNameSchema
certificateNameSchema: syncOption?.defaultCertificateNameSchema,
...(destination === PkiSync.Chef && {
fieldMappings: {
certificate: "certificate",
privateKey: "private_key",
certificateChain: "certificate_chain",
caCertificate: "ca_certificate"
}
})
},
...initialData
} as Partial<TPkiSyncForm>,
@@ -78,11 +112,10 @@ export const CreatePkiSyncForm = ({ destination, onComplete, onCancel, initialDa
});
createNotification({
text: `Successfully created ${destinationName} Certificate Sync${
certificateIds && certificateIds.length > 0
text: `Successfully created ${destinationName} Certificate Sync${certificateIds && certificateIds.length > 0
? ` with ${certificateIds.length} certificate(s)`
: ""
}`,
}`,
type: "success"
});
onComplete(pkiSync);
@@ -167,10 +200,10 @@ export const CreatePkiSyncForm = ({ destination, onComplete, onCancel, initialDa
);
return (
<form className={twMerge(isFinalStep && "max-h-[70vh] overflow-y-auto")}>
<form className="flex max-h-[70vh] flex-col overflow-hidden">
<FormProvider {...formMethods}>
<Tab.Group selectedIndex={selectedTabIndex} onChange={setSelectedTabIndex}>
<Tab.List className="-pb-1 mb-6 w-full border-b-2 border-mineshaft-600">
<Tab.List className="-pb-1 mb-6 w-full flex-shrink-0 border-b-2 border-mineshaft-600">
{FORM_TABS.map((tab, index) => (
<Tab
onClick={async (e) => {
@@ -179,10 +212,9 @@ export const CreatePkiSyncForm = ({ destination, onComplete, onCancel, initialDa
setSelectedTabIndex((prev) => (isEnabled ? index : prev));
}}
className={({ selected }) =>
`-mb-[0.14rem] whitespace-nowrap ${index > selectedTabIndex ? "opacity-30" : ""} px-4 py-2 text-sm font-medium outline-hidden disabled:opacity-60 ${
selected
? "border-b-2 border-mineshaft-300 text-mineshaft-200"
: "text-bunker-300"
`-mb-[0.14rem] whitespace-nowrap ${index > selectedTabIndex ? "opacity-30" : ""} px-4 py-2 text-sm font-medium outline-hidden disabled:opacity-60 ${selected
? "border-b-2 border-mineshaft-300 text-mineshaft-200"
: "text-bunker-300"
}`
}
key={tab.key}
@@ -191,11 +223,11 @@ export const CreatePkiSyncForm = ({ destination, onComplete, onCancel, initialDa
</Tab>
))}
</Tab.List>
<Tab.Panels>
<Tab.Panel>
<Tab.Panels className="flex-1 overflow-y-auto">
<Tab.Panel className="max-h-full overflow-y-auto">
<PkiSyncDestinationFields />
</Tab.Panel>
<Tab.Panel>
<Tab.Panel className="max-h-full overflow-y-auto">
<PkiSyncOptionsFields destination={destination} />
<Controller
control={control}
@@ -225,20 +257,25 @@ export const CreatePkiSyncForm = ({ destination, onComplete, onCancel, initialDa
}}
/>
</Tab.Panel>
<Tab.Panel>
{destination === PkiSync.Chef && (
<Tab.Panel className="max-h-full overflow-y-auto">
<PkiSyncFieldMappingsFields destination={destination} />
</Tab.Panel>
)}
<Tab.Panel className="max-h-full overflow-y-auto">
<PkiSyncDetailsFields />
</Tab.Panel>
<Tab.Panel>
<Tab.Panel className="max-h-full overflow-y-auto">
<PkiSyncCertificatesFields />
</Tab.Panel>
<Tab.Panel>
<Tab.Panel className="max-h-full overflow-y-auto">
<PkiSyncReviewFields />
</Tab.Panel>
</Tab.Panels>
</Tab.Group>
</FormProvider>
<div className="flex w-full flex-row-reverse justify-between gap-4 pt-4">
<div className="mt-4 flex w-full flex-shrink-0 flex-row-reverse justify-between gap-4 border-t border-mineshaft-600 pt-4">
<Button onClick={handleNext} colorSchema="secondary">
{isFinalStep ? "Create Sync" : "Next"}
</Button>

View File

@@ -11,6 +11,7 @@ import { TPkiSync, useUpdatePkiSync } from "@app/hooks/api/pkiSyncs";
import { TUpdatePkiSyncForm, UpdatePkiSyncFormSchema } from "./schemas/pki-sync-schema";
import { PkiSyncDestinationFields } from "./PkiSyncDestinationFields";
import { PkiSyncDetailsFields } from "./PkiSyncDetailsFields";
import { PkiSyncFieldMappingsFields } from "./PkiSyncFieldMappingsFields";
import { PkiSyncOptionsFields } from "./PkiSyncOptionsFields";
import { PkiSyncSourceFields } from "./PkiSyncSourceFields";
@@ -66,6 +67,9 @@ export const EditPkiSyncForm = ({ pkiSync, fields, onComplete }: Props) => {
case PkiSyncEditFields.Options:
Component = <PkiSyncOptionsFields destination={pkiSync.destination} />;
break;
case PkiSyncEditFields.Mappings:
Component = <PkiSyncFieldMappingsFields destination={pkiSync.destination} />;
break;
case PkiSyncEditFields.Source:
Component = <PkiSyncSourceFields />;
break;

View File

@@ -0,0 +1,103 @@
import { Controller, useFormContext } from "react-hook-form";
import { FormControl, Input } from "@app/components/v2";
import { PkiSync } from "@app/hooks/api/pkiSyncs";
import { TPkiSyncForm } from "./schemas/pki-sync-schema";
type Props = {
destination?: PkiSync;
};
export const PkiSyncFieldMappingsFields = ({ destination }: Props) => {
const { control, watch } = useFormContext<TPkiSyncForm>();
const currentDestination = destination || watch("destination");
// Only show field mappings for Chef
if (currentDestination !== PkiSync.Chef) {
return null;
}
return (
<>
<p className="mb-4 text-sm text-bunker-300">
Configure how certificate fields are mapped to your Chef data bag items.
</p>
<div className="grid grid-cols-2 gap-4">
<Controller
control={control}
name="syncOptions.fieldMappings.certificate"
render={({ field, fieldState: { error } }) => (
<FormControl
isError={Boolean(error)}
errorText={error?.message}
label="Certificate Field"
tooltipText="The field name used to store the certificate content in the Chef data bag item."
>
<Input {...field} placeholder="certificate" />
</FormControl>
)}
/>
<Controller
control={control}
name="syncOptions.fieldMappings.privateKey"
render={({ field, fieldState: { error } }) => (
<FormControl
isError={Boolean(error)}
errorText={error?.message}
label="Private Key Field"
tooltipText="The field name used to store the private key content in the Chef data bag item."
>
<Input {...field} placeholder="private_key" />
</FormControl>
)}
/>
<Controller
control={control}
name="syncOptions.fieldMappings.certificateChain"
render={({ field, fieldState: { error } }) => (
<FormControl
isError={Boolean(error)}
errorText={error?.message}
label="Certificate Chain Field"
tooltipText="The field name used to store the certificate chain content in the Chef data bag item."
>
<Input {...field} placeholder="certificate_chain" />
</FormControl>
)}
/>
<Controller
control={control}
name="syncOptions.fieldMappings.caCertificate"
render={({ field, fieldState: { error } }) => (
<FormControl
isError={Boolean(error)}
errorText={error?.message}
label="CA Certificate Field"
tooltipText="The field name used to store the CA certificate content in the Chef data bag item."
>
<Input {...field} placeholder="ca_certificate" />
</FormControl>
)}
/>
</div>
<div className="mt-6 rounded-lg border border-mineshaft-600 bg-mineshaft-800 p-4">
<h4 className="mb-2 text-sm font-medium text-mineshaft-100">Preview JSON Structure</h4>
<pre className="text-xs text-bunker-300">
{`{
"id": "certificate-item-name",
"${watch("syncOptions.fieldMappings.certificate") || "certificate"}": "<certificate-content>",
"${watch("syncOptions.fieldMappings.privateKey") || "private_key"}": "<private-key-content>",
"${watch("syncOptions.fieldMappings.certificateChain") || "certificate_chain"}": "<certificate-chain-content>",
"${watch("syncOptions.fieldMappings.caCertificate") || "ca_certificate"}": "<ca-certificate-content>"
}`}
</pre>
</div>
</>
);
};

View File

@@ -4,6 +4,19 @@ import { PkiSync } from "@app/hooks/api/pkiSyncs";
import { BasePkiSyncSchema } from "./base-pki-sync-schema";
const ChefFieldMappingsSchema = z.object({
certificate: z.string().min(1, "Certificate field name is required").default("certificate"),
privateKey: z.string().min(1, "Private key field name is required").default("private_key"),
certificateChain: z
.string()
.min(1, "Certificate chain field name is required")
.default("certificate_chain"),
caCertificate: z
.string()
.min(1, "CA certificate field name is required")
.default("ca_certificate")
});
const ChefSyncOptionsSchema = z.object({
canImportCertificates: z.boolean().default(false),
canRemoveCertificates: z.boolean().default(true),
@@ -44,7 +57,13 @@ const ChefSyncOptionsSchema = z.object({
message:
"Certificate item name schema must include exactly one {{certificateId}} placeholder. It can also include {{environment}}, {{profileId}}, {{commonName}}, or {{friendlyName}} placeholders. Only alphanumeric characters (a-z, A-Z, 0-9), hyphens (-), and underscores (_) are allowed besides the placeholders."
}
)
),
fieldMappings: ChefFieldMappingsSchema.optional().default({
certificate: "certificate",
privateKey: "private_key",
certificateChain: "certificate_chain",
caCertificate: "ca_certificate"
})
});
export const ChefPkiSyncDestinationSchema = BasePkiSyncSchema(ChefSyncOptionsSchema).merge(

View File

@@ -1,6 +1,7 @@
export enum PkiSyncEditFields {
Details = "details",
Options = "options",
Mappings = "mappings",
Source = "source",
Destination = "destination"
}