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
@@ -139,6 +139,7 @@ export const chefPkiSyncFactory = ({ certificateDAL, certificateSyncDAL }: TChef
cert: string; cert: string;
privateKey: string; privateKey: string;
certificateChain?: string; certificateChain?: string;
caCertificate?: string;
certificateId: string; certificateId: string;
isUpdate: boolean; isUpdate: boolean;
targetItemName: string; targetItemName: string;
@@ -150,15 +151,32 @@ export const chefPkiSyncFactory = ({ certificateDAL, certificateSyncDAL }: TChef
const validationErrors: Array<{ name: string; error: string }> = []; const validationErrors: Array<{ name: string; error: string }> = [];
const syncOptions = pkiSync.syncOptions as const syncOptions = pkiSync.syncOptions as
| { canRemoveCertificates?: boolean; preserveItemOnRenewal?: boolean } | {
canRemoveCertificates?: boolean;
preserveItemOnRenewal?: boolean;
fieldMappings?: {
certificate?: string;
privateKey?: string;
certificateChain?: string;
caCertificate?: string;
metadata?: string;
};
}
| undefined; | undefined;
const canRemoveCertificates = syncOptions?.canRemoveCertificates ?? true; const canRemoveCertificates = syncOptions?.canRemoveCertificates ?? true;
const preserveItemOnRenewal = syncOptions?.preserveItemOnRenewal ?? true; const preserveItemOnRenewal = syncOptions?.preserveItemOnRenewal ?? true;
const fieldMappings = {
certificate: syncOptions?.fieldMappings?.certificate ?? "certificate",
privateKey: syncOptions?.fieldMappings?.privateKey ?? "private_key",
certificateChain: syncOptions?.fieldMappings?.certificateChain ?? "certificate_chain",
caCertificate: syncOptions?.fieldMappings?.caCertificate ?? "ca_certificate"
};
const activeExternalIdentifiers = new Set<string>(); const activeExternalIdentifiers = new Set<string>();
for (const [certName, certData] of Object.entries(certificateMap)) { for (const [certName, certData] of Object.entries(certificateMap)) {
const { cert, privateKey: certPrivateKey, certificateChain, certificateId } = certData; const { cert, privateKey: certPrivateKey, certificateChain, caCertificate, certificateId } = certData;
if (!cert || cert.trim().length === 0) { if (!cert || cert.trim().length === 0) {
validationErrors.push({ validationErrors.push({
@@ -218,6 +236,7 @@ export const chefPkiSyncFactory = ({ certificateDAL, certificateSyncDAL }: TChef
cert, cert,
privateKey: certPrivateKey, privateKey: certPrivateKey,
certificateChain, certificateChain,
caCertificate,
certificateId, certificateId,
isUpdate, isUpdate,
targetItemName, targetItemName,
@@ -240,18 +259,17 @@ export const chefPkiSyncFactory = ({ certificateDAL, certificateSyncDAL }: TChef
cert, cert,
privateKey: certPrivateKey, privateKey: certPrivateKey,
certificateChain, certificateChain,
certificateId, caCertificate,
isUpdate certificateId
} = certificateData; } = certificateData;
try { try {
const chefDataBagItem: ChefCertificateDataBagItem = { const chefDataBagItem: ChefCertificateDataBagItem = {
id: targetItemName, id: targetItemName,
certificate: cert, [fieldMappings.certificate]: cert,
private_key: certPrivateKey, [fieldMappings.privateKey]: certPrivateKey,
...(certificateChain && { certificate_chain: certificateChain }), ...(certificateChain && { [fieldMappings.certificateChain]: certificateChain }),
...(isUpdate ? {} : { created_at: new Date().toISOString() }), ...(caCertificate && { [fieldMappings.caCertificate]: caCertificate })
updated_at: new Date().toISOString()
}; };
const itemExists = chefDataBagItems[targetItemName] === true; const itemExists = chefDataBagItems[targetItemName] === true;
@@ -19,6 +19,13 @@ export const ChefPkiSyncConfigSchema = z.object({
) )
}); });
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 ChefPkiSyncOptionsSchema = z.object({ const ChefPkiSyncOptionsSchema = z.object({
canImportCertificates: z.boolean().default(false), canImportCertificates: z.boolean().default(false),
canRemoveCertificates: z.boolean().default(true), canRemoveCertificates: z.boolean().default(true),
@@ -57,7 +64,13 @@ const ChefPkiSyncOptionsSchema = z.object({
message: message:
"Certificate item name schema must include {{certificateId}} placeholder and result in names that contain only alphanumeric characters, underscores, and hyphens and be 1-255 characters long for Chef data bag items." "Certificate item name schema must include {{certificateId}} placeholder and result in names that contain only alphanumeric characters, underscores, and hyphens and be 1-255 characters long for Chef data bag items."
} }
) ),
fieldMappings: ChefFieldMappingsSchema.optional().default({
certificate: "certificate",
privateKey: "private_key",
certificateChain: "certificate_chain",
caCertificate: "ca_certificate"
})
}); });
export const ChefPkiSyncSchema = PkiSyncSchema.extend({ export const ChefPkiSyncSchema = PkiSyncSchema.extend({
@@ -95,3 +108,5 @@ export const ChefPkiSyncListItemSchema = z.object({
canImportCertificates: z.literal(false), canImportCertificates: z.literal(false),
canRemoveCertificates: z.literal(true) canRemoveCertificates: z.literal(true)
}); });
export { ChefFieldMappingsSchema };
@@ -3,6 +3,7 @@ import { z } from "zod";
import { TChefConnection } from "@app/ee/services/app-connections/chef/chef-connection-types"; import { TChefConnection } from "@app/ee/services/app-connections/chef/chef-connection-types";
import { import {
ChefFieldMappingsSchema,
ChefPkiSyncConfigSchema, ChefPkiSyncConfigSchema,
ChefPkiSyncSchema, ChefPkiSyncSchema,
CreateChefPkiSyncSchema, CreateChefPkiSyncSchema,
@@ -11,6 +12,8 @@ import {
export type TChefPkiSyncConfig = z.infer<typeof ChefPkiSyncConfigSchema>; export type TChefPkiSyncConfig = z.infer<typeof ChefPkiSyncConfigSchema>;
export type TChefFieldMappings = z.infer<typeof ChefFieldMappingsSchema>;
export type TChefPkiSync = z.infer<typeof ChefPkiSyncSchema>; export type TChefPkiSync = z.infer<typeof ChefPkiSyncSchema>;
export type TChefPkiSyncInput = z.infer<typeof CreateChefPkiSyncSchema>; export type TChefPkiSyncInput = z.infer<typeof CreateChefPkiSyncSchema>;
@@ -23,16 +26,7 @@ export type TChefPkiSyncWithCredentials = TChefPkiSync & {
export interface ChefCertificateDataBagItem { export interface ChefCertificateDataBagItem {
id: string; id: string;
certificate: string; [key: string]: string;
private_key: string;
certificate_chain?: string;
common_name?: string;
alternative_names?: string;
serial_number?: string;
not_before?: string;
not_after?: string;
created_at?: string;
updated_at?: string;
} }
export interface SyncCertificatesResult { export interface SyncCertificatesResult {
@@ -236,13 +236,15 @@ export const pkiSyncQueueFactory = ({
} }
let certificateChain: string | undefined; let certificateChain: string | undefined;
let caCertificate: string | undefined;
try { try {
if (certBody.encryptedCertificateChain) { if (certBody.encryptedCertificateChain) {
const decryptedCertChain = await kmsDecryptor({ const decryptedCertChain = await kmsDecryptor({
cipherTextBlob: certBody.encryptedCertificateChain cipherTextBlob: certBody.encryptedCertificateChain
}); });
certificateChain = decryptedCertChain.toString(); certificateChain = decryptedCertChain.toString();
} else if (certificate.caCertId) { }
if (certificate.caCertId) {
const { caCert, caCertChain } = await getCaCertChain({ const { caCert, caCertChain } = await getCaCertChain({
caCertId: certificate.caCertId, caCertId: certificate.caCertId,
certificateAuthorityDAL, certificateAuthorityDAL,
@@ -250,7 +252,10 @@ export const pkiSyncQueueFactory = ({
projectDAL, projectDAL,
kmsService kmsService
}); });
certificateChain = `${caCert}\n${caCertChain}`.trim(); if (!certBody.encryptedCertificateChain) {
certificateChain = `${caCert}\n${caCertChain}`.trim();
}
caCertificate = caCert;
} }
} catch (chainError) { } catch (chainError) {
logger.warn( logger.warn(
@@ -259,6 +264,7 @@ export const pkiSyncQueueFactory = ({
); );
// Continue without certificate chain // Continue without certificate chain
certificateChain = undefined; certificateChain = undefined;
caCertificate = undefined;
} }
let certificateName: string; let certificateName: string;
@@ -298,6 +304,7 @@ export const pkiSyncQueueFactory = ({
cert: certificatePem, cert: certificatePem,
privateKey: certPrivateKey || "", privateKey: certPrivateKey || "",
certificateChain, certificateChain,
caCertificate,
alternativeNames, alternativeNames,
certificateId: certificate.id certificateId: certificate.id
}; };
@@ -73,7 +73,14 @@ export type TPkiSyncListItem = TPkiSync & {
export type TCertificateMap = Record< export type TCertificateMap = Record<
string, string,
{ cert: string; privateKey: string; certificateChain?: string; alternativeNames?: string[]; certificateId?: string } {
cert: string;
privateKey: string;
certificateChain?: string;
caCertificate?: string;
alternativeNames?: string[];
certificateId?: string;
}
>; >;
export type TCreatePkiSyncDTO = { export type TCreatePkiSyncDTO = {
@@ -41,40 +41,54 @@ Any role with these permissions would work such as a custom role with **Data Bag
- **Enable Removal of Expired/Revoked Certificates**: If enabled, Infisical will remove certificates from the destination if they are no longer active in Infisical. - **Enable Removal of Expired/Revoked Certificates**: If enabled, Infisical will remove certificates from the destination if they are no longer active in Infisical.
- **Preserve Data Bag Item on Renewal**: Only applies to certificate renewals. When a certificate is renewed in Infisical, this option controls how the renewed certificate is handled. If enabled, the renewed certificate will update the existing data bag item, preserving the same item name. If disabled, the renewed certificate will be created as a new data bag item with a new name. - **Preserve Data Bag Item on Renewal**: Only applies to certificate renewals. When a certificate is renewed in Infisical, this option controls how the renewed certificate is handled. If enabled, the renewed certificate will update the existing data bag item, preserving the same item name. If disabled, the renewed certificate will be created as a new data bag item with a new name.
- **Update Existing Certificates**: If enabled, Infisical will update existing data bag items when certificate content changes. - **Update Existing Certificates**: If enabled, Infisical will update existing data bag items when certificate content changes.
- **Certificate Name Schema** (Optional): Customize how certificate item names are generated in Chef data bags. Use `{{certificateId}}` as a placeholder for the certificate ID. Available placeholders: `{{certificateId}}`, `{{profileId}}`, `{{commonName}}`, `{{friendlyName}}`, `{{environment}}`. If not specified, defaults to `{{certificateId}}`. - **Certificate Name Schema** (Optional): Customize how certificate item names are generated in Chef data bags. Use `{{certificateId}}` as a placeholder for the certificate ID.
- **Auto-Sync Enabled**: If enabled, certificates will automatically be synced when changes occur. Disable to enforce manual syncing only. - **Auto-Sync Enabled**: If enabled, certificates will automatically be synced when changes occur. Disable to enforce manual syncing only.
5. Configure the **Field Mappings** to customize how certificate data is stored in Chef data bag items, then click **Next**.
![Configure Field Mappings](/images/platform/pki/certificate-syncs/chef/chef-field-mappings.png)
- **Certificate Field**: The field name where the certificate will be stored in the data bag item (default: `certificate`)
- **Private Key Field**: The field name where the private key will be stored in the data bag item (default: `private_key`)
- **Certificate Chain Field**: The field name where the full certificate chain will be stored in the data bag item (default: `certificate_chain`)
- **CA Certificate Field**: The field name where the CA certificate will be stored in the data bag item (default: `ca_certificate`)
<Tip> <Tip>
**Chef Data Bag Item Structure**: Certificates are stored in Chef data bags as items with the following structure: **Chef Data Bag Item Structure**: Certificates are stored in Chef data bags as items with the following structure (field names can be customized via field mappings):
```json ```json
{ {
"id": "certificate-item-name", "id": "certificate-item-name",
"certificate": "-----BEGIN CERTIFICATE-----\n...", "certificate": "-----BEGIN CERTIFICATE-----\n...",
"private_key": "-----BEGIN PRIVATE KEY-----\n...", "private_key": "-----BEGIN PRIVATE KEY-----\n...",
"certificate_chain": "-----BEGIN CERTIFICATE-----\n...", "certificate_chain": "-----BEGIN CERTIFICATE-----\n...",
"metadata": { "ca_certificate": "-----BEGIN CERTIFICATE-----\n..."
"common_name": "example.com", }
"serial_number": "1234567890", ```
"not_before": "2023-01-01T00:00:00Z",
"not_after": "2024-01-01T00:00:00Z" **Example with Custom Field Mappings**:
} ```json
{
"id": "certificate-item-name",
"ssl_cert": "-----BEGIN CERTIFICATE-----\n...",
"ssl_key": "-----BEGIN PRIVATE KEY-----\n...",
"ssl_chain": "-----BEGIN CERTIFICATE-----\n...",
"ssl_ca": "-----BEGIN CERTIFICATE-----\n..."
} }
``` ```
</Tip> </Tip>
5. Configure the **Details** of your Chef Certificate Sync, then click **Next**. 6. Configure the **Details** of your Chef Certificate Sync, then click **Next**.
![Configure Details](/images/platform/pki/certificate-syncs/chef/chef-details.png) ![Configure Details](/images/platform/pki/certificate-syncs/chef/chef-details.png)
- **Name**: The name of your sync. Must be slug-friendly. - **Name**: The name of your sync. Must be slug-friendly.
- **Description**: An optional description for your sync. - **Description**: An optional description for your sync.
6. Select which certificates should be synced to Chef. 7. Select which certificates should be synced to Chef.
![Select Certificates](/images/platform/pki/certificate-syncs/chef/chef-certificates.png) ![Select Certificates](/images/platform/pki/certificate-syncs/chef/chef-certificates.png)
7. Review your Chef Certificate Sync configuration, then click **Create Sync**. 8. Review your Chef Certificate Sync configuration, then click **Create Sync**.
![Confirm Configuration](/images/platform/pki/certificate-syncs/chef/chef-review.png) ![Confirm Configuration](/images/platform/pki/certificate-syncs/chef/chef-review.png)
8. If enabled, your Chef Certificate Sync will begin syncing your certificates to the destination endpoint. 9. If enabled, your Chef Certificate Sync will begin syncing your certificates to the destination endpoint.
![Sync Certificates](/images/platform/pki/certificate-syncs/chef/chef-synced.png) ![Sync Certificates](/images/platform/pki/certificate-syncs/chef/chef-synced.png)
</Tab> </Tab>
<Tab title="API"> <Tab title="API">
@@ -105,11 +119,15 @@ Any role with these permissions would work such as a custom role with **Data Bag
], ],
"syncOptions": { "syncOptions": {
"canRemoveCertificates": true, "canRemoveCertificates": true,
"preserveItemOnRenewal": true, "preserveArn": true,
"updateExistingCertificates": true, "canImportCertificates": false,
"certificateNameSchema": "myapp-{{certificateId}}", "certificateNameSchema": "myapp-{{certificateId}}",
"includeMetadata": true, "fieldMappings": {
"encryptDataBag": true "certificate": "ssl_cert",
"privateKey": "ssl_key",
"certificateChain": "ssl_chain",
"caCertificate": "ssl_ca"
}
}, },
"destinationConfig": { "destinationConfig": {
"dataBagName": "ssl_certificates" "dataBagName": "ssl_certificates"
@@ -117,6 +135,38 @@ Any role with these permissions would work such as a custom role with **Data Bag
}' }'
``` ```
### Example with Default Field Mappings
```bash Request
curl --request POST \
--url https://app.infisical.com/api/v1/pki/syncs/chef \
--header 'Authorization: Bearer <access-token>' \
--header 'Content-Type: application/json' \
--data '{
"name": "my-chef-cert-sync-default",
"projectId": "3c90c3cc-0d44-4b50-8888-8dd25736052a",
"description": "Chef sync with default field mappings",
"connectionId": "3c90c3cc-0d44-4b50-8888-8dd25736052a",
"destination": "chef",
"isAutoSyncEnabled": true,
"syncOptions": {
"canRemoveCertificates": true,
"preserveArn": true,
"canImportCertificates": false,
"certificateNameSchema": "{{commonName}}-{{certificateId}}",
"fieldMappings": {
"certificate": "certificate",
"privateKey": "private_key",
"certificateChain": "certificate_chain",
"caCertificate": "ca_certificate"
}
},
"destinationConfig": {
"dataBagName": "certificates"
}
}'
```
### Sample response ### Sample response
```json Response ```json Response
@@ -132,11 +182,15 @@ Any role with these permissions would work such as a custom role with **Data Bag
}, },
"syncOptions": { "syncOptions": {
"canRemoveCertificates": true, "canRemoveCertificates": true,
"preserveItemOnRenewal": true, "preserveArn": true,
"updateExistingCertificates": true, "canImportCertificates": false,
"certificateNameSchema": "myapp-{{certificateId}}", "certificateNameSchema": "myapp-{{certificateId}}",
"includeMetadata": true, "fieldMappings": {
"encryptDataBag": true "certificate": "ssl_cert",
"privateKey": "ssl_key",
"certificateChain": "ssl_chain",
"caCertificate": "ssl_ca"
}
}, },
"projectId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", "projectId": "3c90c3cc-0d44-4b50-8888-8dd25736052a",
"connectionId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", "connectionId": "3c90c3cc-0d44-4b50-8888-8dd25736052a",
@@ -153,11 +207,12 @@ Any role with these permissions would work such as a custom role with **Data Bag
Your Chef Certificate Sync will: Your Chef Certificate Sync will:
- **Automatic Deployment**: Deploy certificates in Infisical to Chef data bags. - **Automatic Deployment**: Deploy certificates in Infisical to Chef data bags with customizable field names
- **Certificate Updates**: Update certificates in Chef data bags when renewals occur - **Certificate Updates**: Update certificates in Chef data bags when renewals occur
- **Expiration Handling**: Optionally remove expired certificates from Chef data bags (if enabled) - **Expiration Handling**: Optionally remove expired certificates from Chef data bags (if enabled)
- **Format Preservation**: Maintain certificate format and metadata during sync operations - **Format Preservation**: Maintain certificate format during sync operations
- **Data Bag Encryption**: Support Chef's encrypted data bag functionality for secure storage - **Field Customization**: Map certificate data to custom field names that match your Chef cookbook requirements
- **CA Certificate Support**: Include CA certificates in data bag items for complete certificate chain management
<Note> <Note>
Chef Certificate Syncs support both automatic and manual Chef Certificate Syncs support both automatic and manual
Binary file not shown.

After

Width:  |  Height:  |  Size: 345 KiB

@@ -64,7 +64,7 @@ export const CreatePkiSyncModal = ({
"Add Sync" "Add Sync"
) )
} }
className="max-w-2xl" className="max-w-3xl"
bodyClassName="overflow-visible" bodyClassName="overflow-visible"
subTitle={ subTitle={
selectedSync ? undefined : "Select a third-party service to sync certificates to." selectedSync ? undefined : "Select a third-party service to sync certificates to."
@@ -15,11 +15,13 @@ type Props = {
export const EditPkiSyncModal = ({ pkiSync, onOpenChange, fields, ...props }: Props) => { export const EditPkiSyncModal = ({ pkiSync, onOpenChange, fields, ...props }: Props) => {
if (!pkiSync) return null; if (!pkiSync) return null;
const modalClassName = fields === PkiSyncEditFields.Mappings ? "max-w-4xl" : "max-w-2xl";
return ( return (
<Modal {...props} onOpenChange={onOpenChange}> <Modal {...props} onOpenChange={onOpenChange}>
<ModalContent <ModalContent
title={<PkiSyncModalHeader isConfigured destination={pkiSync.destination} />} title={<PkiSyncModalHeader isConfigured destination={pkiSync.destination} />}
className="max-w-2xl" className={modalClassName}
bodyClassName="overflow-visible" bodyClassName="overflow-visible"
> >
<EditPkiSyncForm onComplete={() => onOpenChange(false)} fields={fields} pkiSync={pkiSync} /> <EditPkiSyncForm onComplete={() => onOpenChange(false)} fields={fields} pkiSync={pkiSync} />
@@ -4,7 +4,6 @@ import { faInfoCircle } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Tab } from "@headlessui/react"; import { Tab } from "@headlessui/react";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { twMerge } from "tailwind-merge";
import { createNotification } from "@app/components/notifications"; import { createNotification } from "@app/components/notifications";
import { Button, FormControl, Switch } from "@app/components/v2"; 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 { PkiSyncCertificatesFields } from "./PkiSyncCertificatesFields";
import { PkiSyncDestinationFields } from "./PkiSyncDestinationFields"; import { PkiSyncDestinationFields } from "./PkiSyncDestinationFields";
import { PkiSyncDetailsFields } from "./PkiSyncDetailsFields"; import { PkiSyncDetailsFields } from "./PkiSyncDetailsFields";
import { PkiSyncFieldMappingsFields } from "./PkiSyncFieldMappingsFields";
import { PkiSyncOptionsFields } from "./PkiSyncOptionsFields"; import { PkiSyncOptionsFields } from "./PkiSyncOptionsFields";
import { PkiSyncReviewFields } from "./PkiSyncReviewFields"; import { PkiSyncReviewFields } from "./PkiSyncReviewFields";
@@ -26,13 +26,38 @@ type Props = {
initialData?: any; initialData?: any;
}; };
const FORM_TABS: { name: string; key: string; fields: (keyof TPkiSyncForm)[] }[] = [ const getFormTabs = (
{ name: "Destination", key: "destination", fields: ["connection", "destinationConfig"] }, destination: PkiSync
{ name: "Sync Options", key: "options", fields: ["syncOptions"] }, ): { name: string; key: string; fields: (keyof TPkiSyncForm)[] }[] => {
{ name: "Details", key: "details", fields: ["name", "description"] }, const baseTabs = [
{ name: "Certificates", key: "certificates", fields: ["certificateIds"] }, {
{ name: "Review", key: "review", fields: [] } 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) => { export const CreatePkiSyncForm = ({ destination, onComplete, onCancel, initialData }: Props) => {
const createPkiSync = useCreatePkiSync(); const createPkiSync = useCreatePkiSync();
@@ -42,6 +67,7 @@ export const CreatePkiSyncForm = ({ destination, onComplete, onCancel, initialDa
const [showConfirmation, setShowConfirmation] = useState(false); const [showConfirmation, setShowConfirmation] = useState(false);
const [selectedTabIndex, setSelectedTabIndex] = useState(0); const [selectedTabIndex, setSelectedTabIndex] = useState(0);
const FORM_TABS = getFormTabs(destination);
const { syncOption } = usePkiSyncOption(destination); const { syncOption } = usePkiSyncOption(destination);
@@ -55,7 +81,15 @@ export const CreatePkiSyncForm = ({ destination, onComplete, onCancel, initialDa
canImportCertificates: false, canImportCertificates: false,
canRemoveCertificates: false, canRemoveCertificates: false,
preserveArn: true, preserveArn: true,
certificateNameSchema: syncOption?.defaultCertificateNameSchema certificateNameSchema: syncOption?.defaultCertificateNameSchema,
...(destination === PkiSync.Chef && {
fieldMappings: {
certificate: "certificate",
privateKey: "private_key",
certificateChain: "certificate_chain",
caCertificate: "ca_certificate"
}
})
}, },
...initialData ...initialData
} as Partial<TPkiSyncForm>, } as Partial<TPkiSyncForm>,
@@ -78,11 +112,10 @@ export const CreatePkiSyncForm = ({ destination, onComplete, onCancel, initialDa
}); });
createNotification({ createNotification({
text: `Successfully created ${destinationName} Certificate Sync${ text: `Successfully created ${destinationName} Certificate Sync${certificateIds && certificateIds.length > 0
certificateIds && certificateIds.length > 0
? ` with ${certificateIds.length} certificate(s)` ? ` with ${certificateIds.length} certificate(s)`
: "" : ""
}`, }`,
type: "success" type: "success"
}); });
onComplete(pkiSync); onComplete(pkiSync);
@@ -167,10 +200,10 @@ export const CreatePkiSyncForm = ({ destination, onComplete, onCancel, initialDa
); );
return ( return (
<form className={twMerge(isFinalStep && "max-h-[70vh] overflow-y-auto")}> <form className="flex max-h-[70vh] flex-col overflow-hidden">
<FormProvider {...formMethods}> <FormProvider {...formMethods}>
<Tab.Group selectedIndex={selectedTabIndex} onChange={setSelectedTabIndex}> <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) => ( {FORM_TABS.map((tab, index) => (
<Tab <Tab
onClick={async (e) => { onClick={async (e) => {
@@ -179,10 +212,9 @@ export const CreatePkiSyncForm = ({ destination, onComplete, onCancel, initialDa
setSelectedTabIndex((prev) => (isEnabled ? index : prev)); setSelectedTabIndex((prev) => (isEnabled ? index : prev));
}} }}
className={({ selected }) => className={({ selected }) =>
`-mb-[0.14rem] whitespace-nowrap ${index > selectedTabIndex ? "opacity-30" : ""} px-4 py-2 text-sm font-medium outline-hidden disabled:opacity-60 ${ `-mb-[0.14rem] whitespace-nowrap ${index > selectedTabIndex ? "opacity-30" : ""} px-4 py-2 text-sm font-medium outline-hidden disabled:opacity-60 ${selected
selected ? "border-b-2 border-mineshaft-300 text-mineshaft-200"
? "border-b-2 border-mineshaft-300 text-mineshaft-200" : "text-bunker-300"
: "text-bunker-300"
}` }`
} }
key={tab.key} key={tab.key}
@@ -191,11 +223,11 @@ export const CreatePkiSyncForm = ({ destination, onComplete, onCancel, initialDa
</Tab> </Tab>
))} ))}
</Tab.List> </Tab.List>
<Tab.Panels> <Tab.Panels className="flex-1 overflow-y-auto">
<Tab.Panel> <Tab.Panel className="max-h-full overflow-y-auto">
<PkiSyncDestinationFields /> <PkiSyncDestinationFields />
</Tab.Panel> </Tab.Panel>
<Tab.Panel> <Tab.Panel className="max-h-full overflow-y-auto">
<PkiSyncOptionsFields destination={destination} /> <PkiSyncOptionsFields destination={destination} />
<Controller <Controller
control={control} control={control}
@@ -225,20 +257,25 @@ export const CreatePkiSyncForm = ({ destination, onComplete, onCancel, initialDa
}} }}
/> />
</Tab.Panel> </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 /> <PkiSyncDetailsFields />
</Tab.Panel> </Tab.Panel>
<Tab.Panel> <Tab.Panel className="max-h-full overflow-y-auto">
<PkiSyncCertificatesFields /> <PkiSyncCertificatesFields />
</Tab.Panel> </Tab.Panel>
<Tab.Panel> <Tab.Panel className="max-h-full overflow-y-auto">
<PkiSyncReviewFields /> <PkiSyncReviewFields />
</Tab.Panel> </Tab.Panel>
</Tab.Panels> </Tab.Panels>
</Tab.Group> </Tab.Group>
</FormProvider> </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"> <Button onClick={handleNext} colorSchema="secondary">
{isFinalStep ? "Create Sync" : "Next"} {isFinalStep ? "Create Sync" : "Next"}
</Button> </Button>
@@ -11,6 +11,7 @@ import { TPkiSync, useUpdatePkiSync } from "@app/hooks/api/pkiSyncs";
import { TUpdatePkiSyncForm, UpdatePkiSyncFormSchema } from "./schemas/pki-sync-schema"; import { TUpdatePkiSyncForm, UpdatePkiSyncFormSchema } from "./schemas/pki-sync-schema";
import { PkiSyncDestinationFields } from "./PkiSyncDestinationFields"; import { PkiSyncDestinationFields } from "./PkiSyncDestinationFields";
import { PkiSyncDetailsFields } from "./PkiSyncDetailsFields"; import { PkiSyncDetailsFields } from "./PkiSyncDetailsFields";
import { PkiSyncFieldMappingsFields } from "./PkiSyncFieldMappingsFields";
import { PkiSyncOptionsFields } from "./PkiSyncOptionsFields"; import { PkiSyncOptionsFields } from "./PkiSyncOptionsFields";
import { PkiSyncSourceFields } from "./PkiSyncSourceFields"; import { PkiSyncSourceFields } from "./PkiSyncSourceFields";
@@ -66,6 +67,9 @@ export const EditPkiSyncForm = ({ pkiSync, fields, onComplete }: Props) => {
case PkiSyncEditFields.Options: case PkiSyncEditFields.Options:
Component = <PkiSyncOptionsFields destination={pkiSync.destination} />; Component = <PkiSyncOptionsFields destination={pkiSync.destination} />;
break; break;
case PkiSyncEditFields.Mappings:
Component = <PkiSyncFieldMappingsFields destination={pkiSync.destination} />;
break;
case PkiSyncEditFields.Source: case PkiSyncEditFields.Source:
Component = <PkiSyncSourceFields />; Component = <PkiSyncSourceFields />;
break; break;
@@ -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>
</>
);
};
@@ -4,6 +4,19 @@ import { PkiSync } from "@app/hooks/api/pkiSyncs";
import { BasePkiSyncSchema } from "./base-pki-sync-schema"; 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({ const ChefSyncOptionsSchema = z.object({
canImportCertificates: z.boolean().default(false), canImportCertificates: z.boolean().default(false),
canRemoveCertificates: z.boolean().default(true), canRemoveCertificates: z.boolean().default(true),
@@ -44,7 +57,13 @@ const ChefSyncOptionsSchema = z.object({
message: 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." "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( export const ChefPkiSyncDestinationSchema = BasePkiSyncSchema(ChefSyncOptionsSchema).merge(
@@ -1,6 +1,7 @@
export enum PkiSyncEditFields { export enum PkiSyncEditFields {
Details = "details", Details = "details",
Options = "options", Options = "options",
Mappings = "mappings",
Source = "source", Source = "source",
Destination = "destination" Destination = "destination"
} }
@@ -2,6 +2,13 @@ import { AppConnection } from "@app/hooks/api/appConnections/enums";
import { CertificateSyncStatus, PkiSyncStatus } from "../enums"; import { CertificateSyncStatus, PkiSyncStatus } from "../enums";
export type TChefFieldMappings = {
certificate: string;
privateKey: string;
certificateChain: string;
caCertificate: string;
};
export type RootPkiSyncOptions = { export type RootPkiSyncOptions = {
canImportCertificates: boolean; canImportCertificates: boolean;
canRemoveCertificates: boolean; canRemoveCertificates: boolean;
@@ -11,6 +18,7 @@ export type RootPkiSyncOptions = {
enableVersioning?: boolean; enableVersioning?: boolean;
preserveItemOnRenewal?: boolean; preserveItemOnRenewal?: boolean;
updateExistingCertificates?: boolean; updateExistingCertificates?: boolean;
fieldMappings?: TChefFieldMappings;
}; };
export type TRootPkiSync = { export type TRootPkiSync = {
@@ -21,6 +21,7 @@ import {
PkiSyncCertificatesSection, PkiSyncCertificatesSection,
PkiSyncDestinationSection, PkiSyncDestinationSection,
PkiSyncDetailsSection, PkiSyncDetailsSection,
PkiSyncFieldMappingsSection,
PkiSyncOptionsSection PkiSyncOptionsSection
} from "./components"; } from "./components";
@@ -63,6 +64,7 @@ const PageContent = () => {
const handleEditDetails = () => handlePopUpOpen("editSync", PkiSyncEditFields.Details); const handleEditDetails = () => handlePopUpOpen("editSync", PkiSyncEditFields.Details);
const handleEditOptions = () => handlePopUpOpen("editSync", PkiSyncEditFields.Options); const handleEditOptions = () => handlePopUpOpen("editSync", PkiSyncEditFields.Options);
const handleEditMappings = () => handlePopUpOpen("editSync", PkiSyncEditFields.Mappings);
const handleEditDestination = () => handlePopUpOpen("editSync", PkiSyncEditFields.Destination); const handleEditDestination = () => handlePopUpOpen("editSync", PkiSyncEditFields.Destination);
return ( return (
@@ -103,6 +105,7 @@ const PageContent = () => {
<div className="mr-4 flex w-72 flex-col gap-4"> <div className="mr-4 flex w-72 flex-col gap-4">
<PkiSyncDetailsSection pkiSync={pkiSync} onEditDetails={handleEditDetails} /> <PkiSyncDetailsSection pkiSync={pkiSync} onEditDetails={handleEditDetails} />
<PkiSyncOptionsSection pkiSync={pkiSync} onEditOptions={handleEditOptions} /> <PkiSyncOptionsSection pkiSync={pkiSync} onEditOptions={handleEditOptions} />
<PkiSyncFieldMappingsSection pkiSync={pkiSync} onEditMappings={handleEditMappings} />
</div> </div>
<div className="flex flex-1 flex-col gap-4"> <div className="flex flex-1 flex-col gap-4">
<PkiSyncDestinationSection <PkiSyncDestinationSection
@@ -0,0 +1,93 @@
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) => {
// Only show for Chef PKI syncs
if (pkiSync.destination !== PkiSync.Chef) {
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>
);
};
@@ -3,5 +3,6 @@ export { PkiSyncAuditLogsSection } from "./PkiSyncAuditLogsSection";
export { PkiSyncCertificatesSection } from "./PkiSyncCertificatesSection"; export { PkiSyncCertificatesSection } from "./PkiSyncCertificatesSection";
export { PkiSyncDestinationSection } from "./PkiSyncDestinationSection"; export { PkiSyncDestinationSection } from "./PkiSyncDestinationSection";
export { PkiSyncDetailsSection } from "./PkiSyncDetailsSection"; export { PkiSyncDetailsSection } from "./PkiSyncDetailsSection";
export { PkiSyncFieldMappingsSection } from "./PkiSyncFieldMappingsSection";
export { PkiSyncOptionsSection } from "./PkiSyncOptionsSection"; export { PkiSyncOptionsSection } from "./PkiSyncOptionsSection";
export { PkiSyncSourceSection } from "./PkiSyncSourceSection"; export { PkiSyncSourceSection } from "./PkiSyncSourceSection";