Merge pull request #4897 from Infisical/feat/PKI-29

Add Chef PKI sync
This commit is contained in:
carlosmonastyrski
2025-11-20 17:28:40 -03:00
committed by GitHub
100 changed files with 3409 additions and 72 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

@@ -0,0 +1,50 @@
import { Controller, useFormContext } from "react-hook-form";
import { FormControl, Select, SelectItem } from "@app/components/v2";
import { AWS_REGIONS } from "@app/helpers/appConnections";
import { PkiSync } from "@app/hooks/api/pkiSyncs";
import { TPkiSyncForm } from "./schemas/pki-sync-schema";
import { PkiSyncConnectionField } from "./PkiSyncConnectionField";
export const AwsSecretsManagerPkiSyncFields = () => {
const { control, setValue } = useFormContext<
TPkiSyncForm & { destination: PkiSync.AwsSecretsManager }
>();
return (
<>
<PkiSyncConnectionField
onChange={() => {
setValue("destinationConfig.region", "");
}}
/>
<Controller
name="destinationConfig.region"
control={control}
render={({ field, fieldState: { error } }) => (
<FormControl
isError={Boolean(error)}
errorText={error?.message}
label="AWS Region"
tooltipText="Select the AWS region where your secrets will be stored in AWS Secrets Manager."
>
<Select
value={field.value}
onValueChange={field.onChange}
className="w-full border border-mineshaft-500 capitalize"
position="popper"
placeholder="Select an AWS region"
>
{AWS_REGIONS.map(({ name, slug }) => (
<SelectItem value={slug} key={slug}>
{name}
</SelectItem>
))}
</Select>
</FormControl>
)}
/>
</>
);
};

View File

@@ -0,0 +1,35 @@
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";
import { PkiSyncConnectionField } from "./PkiSyncConnectionField";
export const ChefPkiSyncFields = () => {
const { control, setValue } = useFormContext<TPkiSyncForm & { destination: PkiSync.Chef }>();
return (
<>
<PkiSyncConnectionField
onChange={() => {
setValue("destinationConfig.dataBagName", "");
}}
/>
<Controller
name="destinationConfig.dataBagName"
control={control}
render={({ field, fieldState: { error } }) => (
<FormControl
isError={Boolean(error)}
errorText={error?.message}
label="Data Bag Name"
tooltipText="Enter your Chef data bag name where certificates will be stored. This data bag will be used to store SSL/TLS certificates, private keys, and certificate chains. Data bag names must contain only alphanumeric characters, underscores, and hyphens."
>
<Input {...field} placeholder="ssl_certificates" maxLength={255} />
</FormControl>
)}
/>
</>
);
};

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 || destination === PkiSync.AwsSecretsManager) {
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,19 @@ export const CreatePkiSyncForm = ({ destination, onComplete, onCancel, initialDa
canImportCertificates: false,
canRemoveCertificates: false,
preserveArn: true,
certificateNameSchema: syncOption?.defaultCertificateNameSchema
certificateNameSchema: syncOption?.defaultCertificateNameSchema,
...((destination === PkiSync.Chef || destination === PkiSync.AwsSecretsManager) && {
fieldMappings: {
certificate: "certificate",
privateKey: "private_key",
certificateChain: "certificate_chain",
caCertificate: "ca_certificate"
}
}),
...(destination === PkiSync.AwsSecretsManager && {
preserveSecretOnRenewal: true,
updateExistingCertificates: true
})
},
...initialData
} as Partial<TPkiSyncForm>,
@@ -167,10 +205,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) => {
@@ -191,11 +229,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 +263,25 @@ export const CreatePkiSyncForm = ({ destination, onComplete, onCancel, initialDa
}}
/>
</Tab.Panel>
<Tab.Panel>
{(destination === PkiSync.Chef || destination === PkiSync.AwsSecretsManager) && (
<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

@@ -4,7 +4,9 @@ import { PkiSync } from "@app/hooks/api/pkiSyncs";
import { TPkiSyncForm } from "./schemas/pki-sync-schema";
import { AwsCertificateManagerPkiSyncFields } from "./AwsCertificateManagerPkiSyncFields";
import { AwsSecretsManagerPkiSyncFields } from "./AwsSecretsManagerPkiSyncFields";
import { AzureKeyVaultPkiSyncFields } from "./AzureKeyVaultPkiSyncFields";
import { ChefPkiSyncFields } from "./ChefPkiSyncFields";
export const PkiSyncDestinationFields = () => {
const { watch } = useFormContext<TPkiSyncForm>();
@@ -16,6 +18,10 @@ export const PkiSyncDestinationFields = () => {
return <AzureKeyVaultPkiSyncFields />;
case PkiSync.AwsCertificateManager:
return <AwsCertificateManagerPkiSyncFields />;
case PkiSync.AwsSecretsManager:
return <AwsSecretsManagerPkiSyncFields />;
case PkiSync.Chef:
return <ChefPkiSyncFields />;
default:
return (
<div className="flex items-center justify-center rounded-md border border-red-500 bg-red-100 p-4 text-red-700">

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");
if (currentDestination !== PkiSync.Chef && currentDestination !== PkiSync.AwsSecretsManager) {
return null;
}
return (
<>
<p className="mb-4 text-sm text-bunker-300">
Configure how certificate fields are mapped to your{" "}
{currentDestination === PkiSync.Chef ? "Chef data bag items" : "AWS secrets"}.
</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 ${currentDestination === PkiSync.Chef ? "Chef data bag item" : "AWS secret"}.`}
>
<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 ${currentDestination === PkiSync.Chef ? "Chef data bag item" : "AWS secret"}.`}
>
<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 ${currentDestination === PkiSync.Chef ? "Chef data bag item" : "AWS secret"}.`}
>
<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 ${currentDestination === PkiSync.Chef ? "Chef data bag item" : "AWS secret"}.`}
>
<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

@@ -95,6 +95,48 @@ export const PkiSyncOptionsFields = ({ destination }: Props) => {
)}
/>
<Controller
control={control}
name="syncOptions.includeRootCa"
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl isError={Boolean(error)} errorText={error?.message}>
<Switch
className="bg-mineshaft-400/80 shadow-inner data-[state=checked]:bg-green/80"
id="include-root-ca"
thumbClassName="bg-mineshaft-800"
onCheckedChange={onChange}
isChecked={value}
>
<p>
Include Root CA in Certificate Chain{" "}
<Tooltip
className="max-w-md"
content={
<>
<p>
When enabled, the full certificate chain including the root CA will be
synced to the destination.
</p>
<p className="mt-4">
When disabled, the root CA will be excluded from the certificate chain
during sync operations, reducing the size of the synced certificate chain.
</p>
<p className="mt-4">
Most applications and services work correctly with intermediate certificates
only, as they can validate the trust chain up to a root CA they already
trust.
</p>
</>
}
>
<FontAwesomeIcon icon={faQuestionCircle} size="sm" className="ml-1" />
</Tooltip>
</p>
</Switch>
</FormControl>
)}
/>
{currentDestination === PkiSync.AwsCertificateManager && (
<Controller
control={control}
@@ -183,6 +225,97 @@ export const PkiSyncOptionsFields = ({ destination }: Props) => {
/>
)}
{currentDestination === PkiSync.AwsSecretsManager && (
<Controller
control={control}
name="syncOptions.preserveSecretOnRenewal"
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl isError={Boolean(error)} errorText={error?.message}>
<Switch
className="bg-mineshaft-400/80 shadow-inner data-[state=checked]:bg-green/80"
id="preserve-secret-on-renewal"
thumbClassName="bg-mineshaft-800"
onCheckedChange={onChange}
isChecked={value}
>
<p>
Preserve Secret on Renewal{" "}
<Tooltip
className="max-w-md"
content={
<>
<p>
<strong>Only applies to certificate renewals:</strong> When a certificate
is renewed in Infisical, this option controls how the renewed certificate
is handled in AWS Secrets Manager.
</p>
<p className="mt-4">
When enabled, the renewed certificate will update the existing secret,
preserving the same secret name and ARN. This allows consuming services to
continue using the same secret reference without requiring updates.
</p>
<p className="mt-4">
When disabled, the renewed certificate will be created as a new secret
with a new name, and the old secret will be removed.
</p>
</>
}
>
<FontAwesomeIcon icon={faQuestionCircle} size="sm" className="ml-1" />
</Tooltip>
</p>
</Switch>
</FormControl>
)}
/>
)}
{currentDestination === PkiSync.Chef && (
<Controller
control={control}
name="syncOptions.preserveItemOnRenewal"
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl isError={Boolean(error)} errorText={error?.message}>
<Switch
className="bg-mineshaft-400/80 shadow-inner data-[state=checked]:bg-green/80"
id="preserve-item-on-renewal"
thumbClassName="bg-mineshaft-800"
onCheckedChange={onChange}
isChecked={value}
>
<p>
Preserve Data Bag Item on Renewal{" "}
<Tooltip
className="max-w-md"
content={
<>
<p>
<strong>Only applies to certificate renewals:</strong> When a certificate
is renewed in Infisical, this option controls how the renewed certificate
is handled in Chef.
</p>
<p className="mt-4">
When enabled, the renewed certificate will update the existing data bag
item, preserving the same item name. This allows consuming services to
continue using the same data bag item without requiring updates to Chef
cookbooks or recipes.
</p>
<p className="mt-4">
When disabled, the renewed certificate will be created as a new data bag
item with a new name, and the old item will be removed.
</p>
</>
}
>
<FontAwesomeIcon icon={faQuestionCircle} size="sm" className="ml-1" />
</Tooltip>
</p>
</Switch>
</FormControl>
)}
/>
)}
<Controller
control={control}
name="syncOptions.certificateNameSchema"

View File

@@ -7,6 +7,7 @@ import { BasePkiSyncSchema } from "./base-pki-sync-schema";
const AwsCertificateManagerSyncOptionsSchema = z.object({
canImportCertificates: z.boolean().default(false),
canRemoveCertificates: z.boolean().default(false),
includeRootCa: z.boolean().default(false),
preserveArn: z.boolean().default(true),
certificateNameSchema: z
.string()

View File

@@ -0,0 +1,98 @@
import { z } from "zod";
import { PkiSync } from "@app/hooks/api/pkiSyncs";
import { BasePkiSyncSchema } from "./base-pki-sync-schema";
const AwsSecretsManagerFieldMappingsSchema = 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 AwsSecretsManagerSyncOptionsSchema = z.object({
canImportCertificates: z.boolean().default(false),
canRemoveCertificates: z.boolean().default(true),
includeRootCa: z.boolean().default(false),
preserveSecretOnRenewal: z.boolean().default(true),
updateExistingCertificates: z.boolean().default(true),
certificateNameSchema: z
.string()
.optional()
.refine(
(val) => {
if (!val) return true;
const allowedOptionalPlaceholders = [
"{{environment}}",
"{{profileId}}",
"{{commonName}}",
"{{friendlyName}}"
];
const allowedPlaceholdersRegexPart = ["{{certificateId}}", ...allowedOptionalPlaceholders]
.map((p) => p.replace(/[-/\\^$*+?.()|[\]{}]/g, "\\$&"))
.join("|");
const allowedContentRegex = new RegExp(
`^([a-zA-Z0-9_\\-]|${allowedPlaceholdersRegexPart})*$`
);
const contentIsValid = allowedContentRegex.test(val);
if (val.trim()) {
const certificateIdRegex = /\{\{certificateId\}\}/;
const certificateIdIsPresent = certificateIdRegex.test(val);
return contentIsValid && certificateIdIsPresent;
}
return contentIsValid;
},
{
message:
"Certificate 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: AwsSecretsManagerFieldMappingsSchema.optional().default({
certificate: "certificate",
privateKey: "private_key",
certificateChain: "certificate_chain",
caCertificate: "ca_certificate"
})
});
export const AwsSecretsManagerPkiSyncDestinationSchema = BasePkiSyncSchema(
AwsSecretsManagerSyncOptionsSchema
).merge(
z.object({
destination: z.literal(PkiSync.AwsSecretsManager),
destinationConfig: z.object({
region: z.string().min(1, "AWS region is required")
})
})
);
export const UpdateAwsSecretsManagerPkiSyncDestinationSchema =
AwsSecretsManagerPkiSyncDestinationSchema.partial().merge(
z.object({
name: z
.string()
.trim()
.min(1, "Name is required")
.max(255, "Name must be less than 255 characters"),
destination: z.literal(PkiSync.AwsSecretsManager),
connection: z.object({
id: z.string().uuid("Invalid connection ID format"),
name: z
.string()
.min(1, "Connection name is required")
.max(255, "Connection name must be less than 255 characters")
})
})
);

View File

@@ -7,6 +7,7 @@ import { BasePkiSyncSchema } from "./base-pki-sync-schema";
const AzureKeyVaultSyncOptionsSchema = z.object({
canImportCertificates: z.boolean().default(false),
canRemoveCertificates: z.boolean().default(true),
includeRootCa: z.boolean().default(false),
enableVersioning: z.boolean().default(true),
certificateNameSchema: z
.string()

View File

@@ -6,6 +6,7 @@ export const BasePkiSyncSchema = <T extends AnyZodObject | undefined = undefined
const baseSyncOptionsSchema = z.object({
canImportCertificates: z.boolean().default(false),
canRemoveCertificates: z.boolean().default(false),
includeRootCa: z.boolean().default(false),
certificateNameSchema: z
.string()
.optional()

View File

@@ -0,0 +1,102 @@
import { z } from "zod";
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),
includeRootCa: z.boolean().default(false),
preserveItemOnRenewal: z.boolean().default(true),
updateExistingCertificates: z.boolean().default(true),
certificateNameSchema: z
.string()
.optional()
.refine(
(val) => {
if (!val) return true;
const allowedOptionalPlaceholders = [
"{{environment}}",
"{{profileId}}",
"{{commonName}}",
"{{friendlyName}}"
];
const allowedPlaceholdersRegexPart = ["{{certificateId}}", ...allowedOptionalPlaceholders]
.map((p) => p.replace(/[-/\\^$*+?.()|[\]{}]/g, "\\$&"))
.join("|");
const allowedContentRegex = new RegExp(
`^([a-zA-Z0-9_\\-]|${allowedPlaceholdersRegexPart})*$`
);
const contentIsValid = allowedContentRegex.test(val);
if (val.trim()) {
const certificateIdRegex = /\{\{certificateId\}\}/;
const certificateIdIsPresent = certificateIdRegex.test(val);
return contentIsValid && certificateIdIsPresent;
}
return contentIsValid;
},
{
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(
z.object({
destination: z.literal(PkiSync.Chef),
destinationConfig: z.object({
dataBagName: z
.string()
.min(1, "Data bag name is required")
.max(255, "Data bag name must be less than 255 characters")
.regex(
/^[a-zA-Z0-9_-]+$/,
"Data bag name can only contain alphanumeric characters, underscores, and hyphens"
)
})
})
);
export const UpdateChefPkiSyncDestinationSchema = ChefPkiSyncDestinationSchema.partial().merge(
z.object({
name: z
.string()
.trim()
.min(1, "Name is required")
.max(255, "Name must be less than 255 characters"),
destination: z.literal(PkiSync.Chef),
connection: z.object({
id: z.string().uuid("Invalid connection ID format"),
name: z
.string()
.min(1, "Connection name is required")
.max(255, "Connection name must be less than 255 characters")
})
})
);

View File

@@ -4,19 +4,31 @@ import {
AwsCertificateManagerPkiSyncDestinationSchema,
UpdateAwsCertificateManagerPkiSyncDestinationSchema
} from "./aws-certificate-manager-pki-sync-destination-schema";
import {
AwsSecretsManagerPkiSyncDestinationSchema,
UpdateAwsSecretsManagerPkiSyncDestinationSchema
} from "./aws-secrets-manager-pki-sync-destination-schema";
import {
AzureKeyVaultPkiSyncDestinationSchema,
UpdateAzureKeyVaultPkiSyncDestinationSchema
} from "./azure-key-vault-pki-sync-destination-schema";
import {
ChefPkiSyncDestinationSchema,
UpdateChefPkiSyncDestinationSchema
} from "./chef-pki-sync-destination-schema";
const PkiSyncUnionSchema = z.discriminatedUnion("destination", [
AzureKeyVaultPkiSyncDestinationSchema,
AwsCertificateManagerPkiSyncDestinationSchema
AwsCertificateManagerPkiSyncDestinationSchema,
AwsSecretsManagerPkiSyncDestinationSchema,
ChefPkiSyncDestinationSchema
]);
const UpdatePkiSyncUnionSchema = z.discriminatedUnion("destination", [
UpdateAzureKeyVaultPkiSyncDestinationSchema,
UpdateAwsCertificateManagerPkiSyncDestinationSchema
UpdateAwsCertificateManagerPkiSyncDestinationSchema,
UpdateAwsSecretsManagerPkiSyncDestinationSchema,
UpdateChefPkiSyncDestinationSchema
]);
export const PkiSyncFormSchema = PkiSyncUnionSchema;

View File

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

View File

@@ -15,10 +15,20 @@ export const PKI_SYNC_MAP: Record<
[PkiSync.AwsCertificateManager]: {
name: "AWS Certificate Manager",
image: "Amazon Web Services.png"
},
[PkiSync.AwsSecretsManager]: {
name: "AWS Secrets Manager",
image: "Amazon Web Services.png"
},
[PkiSync.Chef]: {
name: "Chef",
image: "Chef.png"
}
};
export const PKI_SYNC_CONNECTION_MAP: Record<PkiSync, AppConnection> = {
[PkiSync.AzureKeyVault]: AppConnection.AzureKeyVault,
[PkiSync.AwsCertificateManager]: AppConnection.AWS
[PkiSync.AwsCertificateManager]: AppConnection.AWS,
[PkiSync.AwsSecretsManager]: AppConnection.AWS,
[PkiSync.Chef]: AppConnection.Chef
};

View File

@@ -1,6 +1,8 @@
export enum PkiSync {
AzureKeyVault = "azure-key-vault",
AwsCertificateManager = "aws-certificate-manager"
AwsCertificateManager = "aws-certificate-manager",
AwsSecretsManager = "aws-secrets-manager",
Chef = "chef"
}
export enum PkiSyncStatus {
@@ -12,7 +14,7 @@ export enum PkiSyncStatus {
export enum CertificateSyncStatus {
Pending = "pending",
Syncing = "syncing",
Running = "running",
Succeeded = "succeeded",
Failed = "failed"
}

View File

@@ -0,0 +1,29 @@
import { AppConnection } from "@app/hooks/api/appConnections/enums";
import { PkiSync } from "../enums";
import { TRootPkiSync } from "./common";
export type TAwsSecretsManagerFieldMappings = {
certificate: string;
privateKey: string;
certificateChain: string;
caCertificate: string;
};
export type TAwsSecretsManagerPkiSync = TRootPkiSync & {
destination: PkiSync.AwsSecretsManager;
destinationConfig: {
region: string;
keyId?: string;
};
connection: {
app: AppConnection.AWS;
name: string;
id: string;
};
syncOptions: TRootPkiSync["syncOptions"] & {
fieldMappings?: TAwsSecretsManagerFieldMappings;
preserveSecretOnRenewal?: boolean;
updateExistingCertificates?: boolean;
};
};

View File

@@ -0,0 +1,16 @@
import { AppConnection } from "@app/hooks/api/appConnections/enums";
import { PkiSync } from "../enums";
import { TRootPkiSync } from "./common";
export type TChefPkiSync = TRootPkiSync & {
destination: PkiSync.Chef;
destinationConfig: {
dataBagName: string;
};
connection: {
app: AppConnection.Chef;
name: string;
id: string;
};
};

View File

@@ -2,11 +2,23 @@ import { AppConnection } from "@app/hooks/api/appConnections/enums";
import { CertificateSyncStatus, PkiSyncStatus } from "../enums";
export type TChefFieldMappings = {
certificate: string;
privateKey: string;
certificateChain: string;
caCertificate: string;
};
export type RootPkiSyncOptions = {
canImportCertificates: boolean;
canRemoveCertificates: boolean;
certificateNamePrefix?: string;
certificateNameSchema?: string;
preserveArn?: boolean;
enableVersioning?: boolean;
preserveItemOnRenewal?: boolean;
updateExistingCertificates?: boolean;
fieldMappings?: TChefFieldMappings;
};
export type TRootPkiSync = {

View File

@@ -1,7 +1,9 @@
import { PkiSync } from "@app/hooks/api/pkiSyncs";
import { TAwsCertificateManagerPkiSync } from "./aws-certificate-manager-sync";
import { TAwsSecretsManagerPkiSync } from "./aws-secrets-manager-sync";
import { TAzureKeyVaultPkiSync } from "./azure-key-vault-sync";
import { TChefPkiSync } from "./chef-sync";
export type TPkiSyncOption = {
name: string;
@@ -16,7 +18,11 @@ export type TPkiSyncOption = {
minCertificateNameLength?: number;
};
export type TPkiSync = TAzureKeyVaultPkiSync | TAwsCertificateManagerPkiSync;
export type TPkiSync =
| TAzureKeyVaultPkiSync
| TAwsCertificateManagerPkiSync
| TAwsSecretsManagerPkiSync
| TChefPkiSync;
export type TListPkiSyncs = { pkiSyncs: TPkiSync[] };
@@ -31,6 +37,17 @@ type TCreatePkiSyncDTOBase = {
canRemoveCertificates: boolean;
certificateNamePrefix?: string;
certificateNameSchema?: string;
preserveArn?: boolean;
enableVersioning?: boolean;
preserveItemOnRenewal?: boolean;
updateExistingCertificates?: boolean;
preserveSecretOnRenewal?: boolean;
fieldMappings?: {
certificate: string;
privateKey: string;
certificateChain: string;
caCertificate: string;
};
};
isAutoSyncEnabled: boolean;
subscriberId?: string | null;
@@ -43,6 +60,7 @@ export type TCreatePkiSyncDTO = TCreatePkiSyncDTOBase & {
destinationConfig: {
vaultBaseUrl?: string;
region?: string;
dataBagName?: string;
};
};
@@ -76,5 +94,7 @@ export type TTriggerPkiSyncRemoveCertificatesDTO = {
};
export * from "./aws-certificate-manager-sync";
export * from "./aws-secrets-manager-sync";
export * from "./azure-key-vault-sync";
export * from "./chef-sync";
export * from "./common";

View File

@@ -354,6 +354,7 @@ export const CertificateIssuanceModal = ({ popUp, handlePopUpToggle, profileId }
<div>
<h4 className="text-sm font-medium text-mineshaft-300">Certificate Details</h4>
<p className="text-sm text-mineshaft-400">Serial Number: {cert.serialNumber}</p>
<p className="text-sm text-mineshaft-400">Certificate Id: {cert.id}</p>
<p className="text-sm text-mineshaft-400">Common Name: {cert.commonName}</p>
<p className="text-sm text-mineshaft-400">Status: {cert.status}</p>
</div>

View File

@@ -21,6 +21,7 @@ import {
PkiSyncCertificatesSection,
PkiSyncDestinationSection,
PkiSyncDetailsSection,
PkiSyncFieldMappingsSection,
PkiSyncOptionsSection
} from "./components";
@@ -63,6 +64,7 @@ const PageContent = () => {
const handleEditDetails = () => handlePopUpOpen("editSync", PkiSyncEditFields.Details);
const handleEditOptions = () => handlePopUpOpen("editSync", PkiSyncEditFields.Options);
const handleEditMappings = () => handlePopUpOpen("editSync", PkiSyncEditFields.Mappings);
const handleEditDestination = () => handlePopUpOpen("editSync", PkiSyncEditFields.Destination);
return (
@@ -103,6 +105,7 @@ const PageContent = () => {
<div className="mr-4 flex w-72 flex-col gap-4">
<PkiSyncDetailsSection pkiSync={pkiSync} onEditDetails={handleEditDetails} />
<PkiSyncOptionsSection pkiSync={pkiSync} onEditOptions={handleEditOptions} />
<PkiSyncFieldMappingsSection pkiSync={pkiSync} onEditMappings={handleEditMappings} />
</div>
<div className="flex flex-1 flex-col gap-4">
<PkiSyncDestinationSection

View File

@@ -42,14 +42,14 @@ type Props = {
const getSyncStatusVariant = (status?: CertificateSyncStatus | null) => {
if (status === CertificateSyncStatus.Succeeded) return "success";
if (status === CertificateSyncStatus.Failed) return "danger";
if (status === CertificateSyncStatus.Syncing) return "neutral";
if (status === CertificateSyncStatus.Running) return "neutral";
return "project";
};
const getSyncStatusText = (status?: CertificateSyncStatus | null) => {
if (status === CertificateSyncStatus.Succeeded) return "Synced";
if (status === CertificateSyncStatus.Failed) return "Failed";
if (status === CertificateSyncStatus.Syncing) return "Syncing";
if (status === CertificateSyncStatus.Running) return "Syncing";
if (status === CertificateSyncStatus.Pending) return "Pending";
return "Unknown";
};

View File

@@ -13,7 +13,9 @@ import { PkiSync, TPkiSync } from "@app/hooks/api/pkiSyncs";
import {
AwsCertificateManagerPkiSyncDestinationSection,
AzureKeyVaultPkiSyncDestinationSection
AwsSecretsManagerPkiSyncDestinationSection,
AzureKeyVaultPkiSyncDestinationSection,
ChefPkiSyncDestinationSection
} from "./PkiSyncDestinationSection/index";
const GenericFieldLabel = ({ label, children }: { label: string; children: React.ReactNode }) => (
@@ -38,9 +40,15 @@ export const PkiSyncDestinationSection = ({ pkiSync, onEditDestination }: Props)
case PkiSync.AwsCertificateManager:
DestinationComponents = <AwsCertificateManagerPkiSyncDestinationSection pkiSync={pkiSync} />;
break;
case PkiSync.AwsSecretsManager:
DestinationComponents = <AwsSecretsManagerPkiSyncDestinationSection pkiSync={pkiSync} />;
break;
case PkiSync.AzureKeyVault:
DestinationComponents = <AzureKeyVaultPkiSyncDestinationSection pkiSync={pkiSync} />;
break;
case PkiSync.Chef:
DestinationComponents = <ChefPkiSyncDestinationSection pkiSync={pkiSync} />;
break;
default:
// For future destinations, return null (no additional fields to show)
DestinationComponents = null;

View File

@@ -0,0 +1,28 @@
import { TAwsSecretsManagerPkiSync, TPkiSync } from "@app/hooks/api/pkiSyncs";
const GenericFieldLabel = ({ label, children }: { label: string; children: React.ReactNode }) => (
<div className="mb-4">
<p className="text-sm font-medium text-mineshaft-300">{label}</p>
<div className="text-sm text-mineshaft-300">{children}</div>
</div>
);
type Props = {
pkiSync: TPkiSync;
};
export const AwsSecretsManagerPkiSyncDestinationSection = ({ pkiSync }: Props) => {
const awsSecretsManagerPkiSync = pkiSync as TAwsSecretsManagerPkiSync;
const { destinationConfig } = awsSecretsManagerPkiSync;
return (
<>
<GenericFieldLabel label="AWS Region">
{destinationConfig.region || "us-east-1"}
</GenericFieldLabel>
{destinationConfig.keyId && (
<GenericFieldLabel label="KMS Key">{destinationConfig.keyId}</GenericFieldLabel>
)}
</>
);
};

View File

@@ -0,0 +1,25 @@
import { TPkiSync } from "@app/hooks/api/pkiSyncs";
const GenericFieldLabel = ({ label, children }: { label: string; children: React.ReactNode }) => (
<div className="mb-4">
<p className="text-sm font-medium text-mineshaft-300">{label}</p>
<div className="text-sm text-mineshaft-300">{children}</div>
</div>
);
type Props = {
pkiSync: TPkiSync;
};
export const ChefPkiSyncDestinationSection = ({ pkiSync }: Props) => {
const dataBagName =
pkiSync.destinationConfig && "dataBagName" in pkiSync.destinationConfig
? pkiSync.destinationConfig.dataBagName
: undefined;
return (
<GenericFieldLabel label="Chef Data Bag Name">
{dataBagName || "Not specified"}
</GenericFieldLabel>
);
};

View File

@@ -1,2 +1,4 @@
export { AwsCertificateManagerPkiSyncDestinationSection } from "./AwsCertificateManagerPkiSyncDestinationSection";
export { AwsSecretsManagerPkiSyncDestinationSection } from "./AwsSecretsManagerPkiSyncDestinationSection";
export { AzureKeyVaultPkiSyncDestinationSection } from "./AzureKeyVaultPkiSyncDestinationSection";
export { ChefPkiSyncDestinationSection } from "./ChefPkiSyncDestinationSection";

View File

@@ -0,0 +1,92 @@
import { subject } from "@casl/ability";
import { faEdit } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { ProjectPermissionCan } from "@app/components/permissions";
import { IconButton } from "@app/components/v2";
import { Badge } from "@app/components/v3";
import { ProjectPermissionSub } from "@app/context";
import { ProjectPermissionPkiSyncActions } from "@app/context/ProjectPermissionContext/types";
import { PkiSync, TPkiSync } from "@app/hooks/api/pkiSyncs";
const GenericFieldLabel = ({
label,
children,
labelClassName
}: {
label: string;
children: React.ReactNode;
labelClassName?: string;
}) => (
<div className="mb-3">
<p className={`mb-1 text-sm font-medium text-mineshaft-300 ${labelClassName || ""}`}>{label}</p>
<div className="text-sm text-mineshaft-400">{children}</div>
</div>
);
type Props = {
pkiSync: TPkiSync;
onEditMappings: VoidFunction;
};
export const PkiSyncFieldMappingsSection = ({ pkiSync, onEditMappings }: Props) => {
if (pkiSync.destination !== PkiSync.Chef && pkiSync.destination !== PkiSync.AwsSecretsManager) {
return null;
}
const fieldMappings = pkiSync.syncOptions?.fieldMappings;
const permissionSubject = subject(ProjectPermissionSub.PkiSyncs, {
subscriberId: pkiSync.subscriberId || ""
});
return (
<div>
<div className="flex w-full flex-col gap-3 rounded-lg border border-mineshaft-600 bg-mineshaft-900 px-4 py-3">
<div className="flex items-center justify-between border-b border-mineshaft-400 pb-2">
<h3 className="text-lg font-medium text-mineshaft-100">Field Mappings</h3>
<ProjectPermissionCan I={ProjectPermissionPkiSyncActions.Edit} a={permissionSubject}>
{(isAllowed) => (
<IconButton
variant="plain"
colorSchema="secondary"
isDisabled={!isAllowed}
ariaLabel="Edit field mappings"
onClick={onEditMappings}
>
<FontAwesomeIcon icon={faEdit} />
</IconButton>
)}
</ProjectPermissionCan>
</div>
<div className="pt-1">
<div className="space-y-3">
<GenericFieldLabel label="Certificate Field">
<Badge variant="neutral" className="max-w-full truncate">
{fieldMappings?.certificate || "certificate"}
</Badge>
</GenericFieldLabel>
<GenericFieldLabel label="Private Key Field">
<Badge variant="neutral" className="max-w-full truncate">
{fieldMappings?.privateKey || "private_key"}
</Badge>
</GenericFieldLabel>
<GenericFieldLabel label="Certificate Chain Field">
<Badge variant="neutral" className="max-w-full truncate">
{fieldMappings?.certificateChain || "certificate_chain"}
</Badge>
</GenericFieldLabel>
<GenericFieldLabel label="CA Certificate Field">
<Badge variant="neutral" className="max-w-full truncate">
{fieldMappings?.caCertificate || "ca_certificate"}
</Badge>
</GenericFieldLabel>
</div>
</div>
</div>
</div>
);
};

View File

@@ -3,5 +3,6 @@ export { PkiSyncAuditLogsSection } from "./PkiSyncAuditLogsSection";
export { PkiSyncCertificatesSection } from "./PkiSyncCertificatesSection";
export { PkiSyncDestinationSection } from "./PkiSyncDestinationSection";
export { PkiSyncDetailsSection } from "./PkiSyncDetailsSection";
export { PkiSyncFieldMappingsSection } from "./PkiSyncFieldMappingsSection";
export { PkiSyncOptionsSection } from "./PkiSyncOptionsSection";
export { PkiSyncSourceSection } from "./PkiSyncSourceSection";