diff --git a/backend/src/lib/api-docs/constants.ts b/backend/src/lib/api-docs/constants.ts index dde9b4e35..cd0782483 100644 --- a/backend/src/lib/api-docs/constants.ts +++ b/backend/src/lib/api-docs/constants.ts @@ -2376,6 +2376,12 @@ export const AppConnections = { }, LARAVEL_FORGE: { apiToken: "The API token used to authenticate with Laravel Forge." + }, + CHEF: { + serverUrl: "The URL of the Chef server to connect to.", + orgName: "The name of the Chef organization to connect to.", + userName: "The username used to access Chef.", + privateKey: "The private key used to access Chef." } } }; diff --git a/backend/src/server/routes/v1/app-connection-routers/app-connection-router.ts b/backend/src/server/routes/v1/app-connection-routers/app-connection-router.ts index c799ef0f0..29f22621f 100644 --- a/backend/src/server/routes/v1/app-connection-routers/app-connection-router.ts +++ b/backend/src/server/routes/v1/app-connection-routers/app-connection-router.ts @@ -48,6 +48,7 @@ import { ChecklyConnectionListItemSchema, SanitizedChecklyConnectionSchema } from "@app/services/app-connection/checkly"; +import { ChefConnectionListItemSchema, SanitizedChefConnectionSchema } from "@app/services/app-connection/chef"; import { CloudflareConnectionListItemSchema, SanitizedCloudflareConnectionSchema @@ -163,7 +164,8 @@ const SanitizedAppConnectionSchema = z.union([ ...SanitizedOktaConnectionSchema.options, ...SanitizedAzureADCSConnectionSchema.options, ...SanitizedRedisConnectionSchema.options, - ...SanitizedLaravelForgeConnectionSchema.options + ...SanitizedLaravelForgeConnectionSchema.options, + ...SanitizedChefConnectionSchema.options ]); const AppConnectionOptionsSchema = z.discriminatedUnion("app", [ @@ -206,7 +208,8 @@ const AppConnectionOptionsSchema = z.discriminatedUnion("app", [ OktaConnectionListItemSchema, AzureADCSConnectionListItemSchema, RedisConnectionListItemSchema, - LaravelForgeConnectionListItemSchema + LaravelForgeConnectionListItemSchema, + ChefConnectionListItemSchema ]); export const registerAppConnectionRouter = async (server: FastifyZodProvider) => { diff --git a/backend/src/server/routes/v1/app-connection-routers/chef-connection-router.ts b/backend/src/server/routes/v1/app-connection-routers/chef-connection-router.ts new file mode 100644 index 000000000..d57bd354f --- /dev/null +++ b/backend/src/server/routes/v1/app-connection-routers/chef-connection-router.ts @@ -0,0 +1,84 @@ +// import z from "zod"; + +// import { readLimit } from "@app/server/config/rateLimiter"; +// import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; +import { + CreateChefConnectionSchema, + SanitizedChefConnectionSchema, + UpdateChefConnectionSchema +} from "@app/services/app-connection/chef"; + +// import { AuthMode } from "@app/services/auth/auth-type"; +import { registerAppConnectionEndpoints } from "./app-connection-endpoints"; + +export const registerChefConnectionRouter = async (server: FastifyZodProvider) => { + registerAppConnectionEndpoints({ + app: AppConnection.Chef, + server, + sanitizedResponseSchema: SanitizedChefConnectionSchema, + createSchema: CreateChefConnectionSchema, + updateSchema: UpdateChefConnectionSchema + }); + + // server.route({ + // method: "GET", + // url: `/:connectionId/data-bags`, + // config: { + // rateLimit: readLimit + // }, + // schema: { + // params: z.object({ + // connectionId: z.string().uuid() + // }), + // response: { + // 200: z + // .object({ + // id: z.string(), + // name: z.string() + // }) + // .array() + // } + // }, + // onRequest: verifyAuth([AuthMode.JWT]), + // handler: async (req) => { + // const { connectionId } = req.params; + // const dataBags = await server.services.appConnection.chef.listDataBags(connectionId, req.permission); + + // return dataBags; + // } + // }); + + // server.route({ + // method: "GET", + // url: `/:connectionId/data-bags/:dataBagName/items`, + // config: { + // rateLimit: readLimit + // }, + // schema: { + // params: z.object({ + // connectionId: z.string().uuid(), + // dataBagName: z.string() + // }), + // response: { + // 200: z + // .object({ + // id: z.string(), + // name: z.string() + // }) + // .array() + // } + // }, + // onRequest: verifyAuth([AuthMode.JWT]), + // handler: async (req) => { + // const { connectionId, dataBagName } = req.params; + // const dataBagItems = await server.services.appConnection.chef.listDataBagItems( + // connectionId, + // dataBagName, + // req.permission + // ); + + // return dataBagItems; + // } + // }); +}; diff --git a/backend/src/server/routes/v1/app-connection-routers/index.ts b/backend/src/server/routes/v1/app-connection-routers/index.ts index 2e3da4420..a24e72766 100644 --- a/backend/src/server/routes/v1/app-connection-routers/index.ts +++ b/backend/src/server/routes/v1/app-connection-routers/index.ts @@ -13,6 +13,7 @@ import { registerAzureKeyVaultConnectionRouter } from "./azure-key-vault-connect import { registerBitbucketConnectionRouter } from "./bitbucket-connection-router"; import { registerCamundaConnectionRouter } from "./camunda-connection-router"; import { registerChecklyConnectionRouter } from "./checkly-connection-router"; +import { registerChefConnectionRouter } from "./chef-connection-router"; import { registerCloudflareConnectionRouter } from "./cloudflare-connection-router"; import { registerDatabricksConnectionRouter } from "./databricks-connection-router"; import { registerDigitalOceanConnectionRouter } from "./digital-ocean-connection-router"; @@ -84,5 +85,6 @@ export const APP_CONNECTION_REGISTER_ROUTER_MAP: Record [value, key]) @@ -204,7 +205,8 @@ export const listAppConnectionOptions = (projectType?: ProjectType) => { getDigitalOceanConnectionListItem(), getNetlifyConnectionListItem(), getOktaConnectionListItem(), - getRedisConnectionListItem() + getRedisConnectionListItem(), + getChefConnectionListItem() ] .filter((option) => { switch (projectType) { @@ -334,7 +336,8 @@ export const validateAppConnectionCredentials = async ( [AppConnection.DigitalOcean]: validateDigitalOceanConnectionCredentials as TAppConnectionCredentialsValidator, [AppConnection.Okta]: validateOktaConnectionCredentials as TAppConnectionCredentialsValidator, [AppConnection.Netlify]: validateNetlifyConnectionCredentials as TAppConnectionCredentialsValidator, - [AppConnection.Redis]: validateRedisConnectionCredentials as TAppConnectionCredentialsValidator + [AppConnection.Redis]: validateRedisConnectionCredentials as TAppConnectionCredentialsValidator, + [AppConnection.Chef]: validateChefConnectionCredentials as TAppConnectionCredentialsValidator }; return VALIDATE_APP_CONNECTION_CREDENTIALS_MAP[appConnection.app](appConnection, gatewayService, gatewayV2Service); @@ -401,6 +404,8 @@ export const getAppConnectionMethodName = (method: TAppConnection["method"]) => case RenderConnectionMethod.ApiKey: case ChecklyConnectionMethod.ApiKey: return "API Key"; + case ChefConnectionMethod.UserKey: + return "User Key"; case SupabaseConnectionMethod.AccessToken: return "Access Token"; default: @@ -474,7 +479,8 @@ export const TRANSITION_CONNECTION_CREDENTIALS_TO_PLATFORM: Record< [AppConnection.Netlify]: platformManagedCredentialsNotSupported, [AppConnection.Okta]: platformManagedCredentialsNotSupported, [AppConnection.Redis]: platformManagedCredentialsNotSupported, - [AppConnection.LaravelForge]: platformManagedCredentialsNotSupported + [AppConnection.LaravelForge]: platformManagedCredentialsNotSupported, + [AppConnection.Chef]: platformManagedCredentialsNotSupported }; export const enterpriseAppCheck = async ( diff --git a/backend/src/services/app-connection/app-connection-maps.ts b/backend/src/services/app-connection/app-connection-maps.ts index c01d9d1b4..93cd18f82 100644 --- a/backend/src/services/app-connection/app-connection-maps.ts +++ b/backend/src/services/app-connection/app-connection-maps.ts @@ -40,7 +40,8 @@ export const APP_CONNECTION_NAME_MAP: Record = { [AppConnection.DigitalOcean]: "DigitalOcean App Platform", [AppConnection.Netlify]: "Netlify", [AppConnection.Okta]: "Okta", - [AppConnection.Redis]: "Redis" + [AppConnection.Redis]: "Redis", + [AppConnection.Chef]: "Chef" }; export const APP_CONNECTION_PLAN_MAP: Record = { @@ -83,5 +84,6 @@ export const APP_CONNECTION_PLAN_MAP: Record>>; @@ -322,6 +329,7 @@ export type TAppConnectionInput = { id: string } & ( | TNetlifyConnectionInput | TOktaConnectionInput | TRedisConnectionInput + | TChefConnectionInput ); export type TSqlConnectionInput = @@ -386,7 +394,8 @@ export type TAppConnectionConfig = | TDigitalOceanConnectionConfig | TNetlifyConnectionConfig | TOktaConnectionConfig - | TRedisConnectionConfig; + | TRedisConnectionConfig + | TChefConnectionConfig; export type TValidateAppConnectionCredentialsSchema = | TValidateAwsConnectionCredentialsSchema @@ -428,7 +437,8 @@ export type TValidateAppConnectionCredentialsSchema = | TValidateDigitalOceanCredentialsSchema | TValidateNetlifyConnectionCredentialsSchema | TValidateOktaConnectionCredentialsSchema - | TValidateRedisConnectionCredentialsSchema; + | TValidateRedisConnectionCredentialsSchema + | TValidateChefConnectionCredentialsSchema; export type TListAwsConnectionKmsKeys = { connectionId: string; diff --git a/backend/src/services/app-connection/chef/chef-connection-enums.ts b/backend/src/services/app-connection/chef/chef-connection-enums.ts new file mode 100644 index 000000000..58e59c3ba --- /dev/null +++ b/backend/src/services/app-connection/chef/chef-connection-enums.ts @@ -0,0 +1,3 @@ +export enum ChefConnectionMethod { + UserKey = "user-key" +} diff --git a/backend/src/services/app-connection/chef/chef-connection-fns.ts b/backend/src/services/app-connection/chef/chef-connection-fns.ts new file mode 100644 index 000000000..fe9e917b0 --- /dev/null +++ b/backend/src/services/app-connection/chef/chef-connection-fns.ts @@ -0,0 +1,264 @@ +import { AxiosError } from "axios"; +import crypto from "crypto"; + +import { request } from "@app/lib/config/request"; +import { BadRequestError } from "@app/lib/errors"; +import { removeTrailingSlash } from "@app/lib/fn"; +import { blockLocalAndPrivateIpAddresses } from "@app/lib/validator"; +import { IntegrationUrls } from "@app/services/integration-auth/integration-list"; + +import { AppConnection } from "../app-connection-enums"; +import { ChefConnectionMethod } from "./chef-connection-enums"; +import { TChefConnection, TChefConnectionConfig, TChefDataBag } from "./chef-connection-types"; + +export const getChefServerUrl = async (serverUrl?: string) => { + const chefServerUrl = serverUrl ? removeTrailingSlash(serverUrl) : IntegrationUrls.CHEF_API_URL; + + await blockLocalAndPrivateIpAddresses(chefServerUrl); + + return chefServerUrl; +}; + +// Helper to ensure private key is in proper PEM format +const formatPrivateKey = (key: string): string => { + let formattedKey = key.trim(); + + // Ensure proper line breaks in PEM format (handle escaped newlines) + formattedKey = formattedKey.replace(/\\n/g, "\n"); + + // Remove any extra whitespace between lines + formattedKey = formattedKey.replace(/\n\s+/g, "\n"); + + // If key doesn't have headers, add PKCS#1 RSA headers + if (!formattedKey.includes("BEGIN")) { + formattedKey = `-----BEGIN RSA PRIVATE KEY-----\n${formattedKey}\n-----END RSA PRIVATE KEY-----`; + } + + // Ensure the key has proper line breaks after headers and before footers + formattedKey = formattedKey.replace(/(-----BEGIN[^-]+-----)\s*/g, "$1\n").replace(/\s*(-----END[^-]+-----)/g, "\n$1"); + + // Remove any duplicate newlines + formattedKey = formattedKey.replace(/\n{3,}/g, "\n\n"); + + return formattedKey; +}; + +// Chef API authentication helper +const getChefAuthHeaders = ( + method: string, + path: string, + body: string, + userId: string, + privateKey: string, + timestamp: string +) => { + const hashedPath = crypto.createHash("sha256").update(path).digest("base64"); + const hashedBody = crypto.createHash("sha256").update(body).digest("base64"); + + console.log("1", { + method, + path, + body, + userId, + privateKey, + timestamp + }); + const canonicalRequest = [ + `Method:${method}`, + `Hashed Path:${hashedPath}`, + `X-Ops-Content-Hash:${hashedBody}`, + `X-Ops-Timestamp:${timestamp}`, + `X-Ops-UserId:${userId}` + ].join("\n"); + + // Format the private key properly + const formattedKey = formatPrivateKey(privateKey); + + console.log("Chef Auth Debug - Key format:", { + formattedKey, + hasHeader: formattedKey.includes("BEGIN"), + keyType: formattedKey.match(/-----BEGIN ([^-]+)-----/)?.[1], + keyLength: formattedKey.length, + startsCorrectly: formattedKey.startsWith("-----BEGIN"), + endsCorrectly: formattedKey.endsWith("-----") + }); + + // Create key object with proper format + let keyObject; + try { + keyObject = crypto.createPrivateKey({ + key: Buffer.from(formattedKey), + format: "pem" + }); + } catch (error) { + console.error("Failed to create private key:", error); + console.error("Key preview (first 100 chars):", formattedKey.substring(0, 100)); + throw new Error(`Invalid private key format: ${error instanceof Error ? error.message : "Unknown error"}`); + } + + // Sign using the key object with SHA-256 (FIPS-compliant) + const signature = crypto.sign("sha256", Buffer.from(canonicalRequest), { + key: keyObject, + padding: crypto.constants.RSA_PKCS1_PADDING + }); + + const signatureBase64 = signature.toString("base64"); + + console.log("4", { signature, signatureBase64 }); + + // Split signature into 60 character chunks as required by Chef API + const signatureLines: string[] = []; + for (let i = 0; i < signatureBase64.length; i += 60) { + signatureLines.push(signatureBase64.substring(i, i + 60)); + } + + console.log("5", signatureLines); + const headers: Record = { + "X-Ops-Sign": "algorithm=sha256;version=1.3", + "X-Ops-UserId": userId, + "X-Ops-Timestamp": timestamp, + "X-Ops-Content-Hash": hashedBody, + "X-Chef-Version": "12.0.2", + "X-Ops-Server-API-Version": "1" + }; + + console.log("6", headers); + + signatureLines.forEach((line, index) => { + headers[`X-Ops-Authorization-${index + 1}`] = line; + }); + + console.log("7", headers); + + return headers; +}; + +export const getChefConnectionListItem = () => { + return { + name: "Chef" as const, + app: AppConnection.Chef as const, + methods: Object.values(ChefConnectionMethod) as [ChefConnectionMethod.UserKey] + }; +}; + +export const listChefDataBags = async (appConnection: TChefConnection): Promise => { + const { + credentials: { serverUrl, userName, privateKey } + } = appConnection; + + try { + const path = "/data"; + const timestamp = `${new Date().toISOString().slice(0, -5)}Z`; + const body = ""; + + const hostServerUrl = await getChefServerUrl(serverUrl); + + const headers = getChefAuthHeaders("GET", path, body, userName, privateKey, timestamp); + + const res = await request.get>(`${hostServerUrl}${path}`, { + headers: { + ...headers, + Accept: "application/json" + } + }); + + // Chef returns data bags as an object with keys being data bag names + return Object.keys(res.data).map((name) => ({ + name, + id: name + })); + } catch (error) { + if (error instanceof AxiosError) { + // throw new BadRequestError({ + // message: `Failed to list Chef data bags: ${error.response?.data?.error || error.message}` + // }); + } + throw new BadRequestError({ + message: "Unable to list Chef data bags" + }); + } +}; + +export const validateChefConnectionCredentials = async (config: TChefConnectionConfig) => { + const { credentials: inputCredentials } = config; + + try { + const path = `/organizations/${inputCredentials.orgName}/users/${inputCredentials.userName}`; + const timestamp = `${new Date().toISOString().slice(0, -5)}Z`; + const body = ""; + + const hostServerUrl = await getChefServerUrl(inputCredentials.serverUrl); + + console.log("Chef validate - attempting connection to:", hostServerUrl); + const headers = getChefAuthHeaders( + "GET", + path, + body, + inputCredentials.userName, + inputCredentials.privateKey, + timestamp + ); + + const data = await request.get(`${hostServerUrl}${path}`, { + headers: { + ...headers, + Accept: "application/json" + } + }); + console.log("Chef validate - data:", data); + } catch (error: unknown) { + if (error instanceof AxiosError) { + const errorData = error.response?.data as { error?: string } | undefined; + console.log("Chef validate - error:", errorData); + throw new BadRequestError({ + message: `Failed to validate Chef credentials: ${errorData?.error || error.message || "Unknown error"}` + }); + } + throw new BadRequestError({ + message: "Unable to validate Chef connection: verify credentials" + }); + } + + return inputCredentials; +}; + +export const listChefDataBagItems = async ( + appConnection: TChefConnection, + dataBagName: string +): Promise => { + const { + credentials: { serverUrl, userName, privateKey } + } = appConnection; + + try { + const path = `/data/${dataBagName}`; + const timestamp = `${new Date().toISOString().slice(0, -5)}Z`; + const body = ""; + + const hostServerUrl = await getChefServerUrl(serverUrl); + + const headers = getChefAuthHeaders("GET", path, body, userName, privateKey, timestamp); + + const res = await request.get>(`${hostServerUrl}${path}`, { + headers: { + ...headers, + Accept: "application/json" + } + }); + + // Chef returns data bag items as an object with keys being item names + return Object.keys(res.data).map((name) => ({ + name, + id: name + })); + } catch (error) { + if (error instanceof AxiosError) { + // throw new BadRequestError({ + // message: `Failed to list Chef data bag items: ${error.response?.data?.error || error.message}` + // }); + } + throw new BadRequestError({ + message: "Unable to list Chef data bag items" + }); + } +}; diff --git a/backend/src/services/app-connection/chef/chef-connection-schemas.ts b/backend/src/services/app-connection/chef/chef-connection-schemas.ts new file mode 100644 index 000000000..e5a3687a2 --- /dev/null +++ b/backend/src/services/app-connection/chef/chef-connection-schemas.ts @@ -0,0 +1,77 @@ +import z from "zod"; + +import { AppConnections } from "@app/lib/api-docs"; +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; +import { + BaseAppConnectionSchema, + GenericCreateAppConnectionFieldsSchema, + GenericUpdateAppConnectionFieldsSchema +} from "@app/services/app-connection/app-connection-schemas"; + +import { ChefConnectionMethod } from "./chef-connection-enums"; + +export const ChefConnectionUserKeyCredentialsSchema = z.object({ + serverUrl: z + .string() + .trim() + .url("Valid Chef Server URL required") + .optional() + .describe(AppConnections.CREDENTIALS.CHEF.serverUrl), + orgName: z + .string() + .trim() + .min(1, "Organization name required") + .max(256, "Organization name cannot exceed 256 characters") + .describe(AppConnections.CREDENTIALS.CHEF.orgName), + userName: z + .string() + .trim() + .min(1, "User name required") + .max(256, "User name cannot exceed 256 characters") + .describe(AppConnections.CREDENTIALS.CHEF.userName), + privateKey: z + .string() + .trim() + .min(1, "Private key required") + .max(16384, "Private key cannot exceed 16384 characters") + .describe(AppConnections.CREDENTIALS.CHEF.privateKey) +}); + +const BaseChefConnectionSchema = BaseAppConnectionSchema.extend({ app: z.literal(AppConnection.Chef) }); + +export const ChefConnectionSchema = BaseChefConnectionSchema.extend({ + method: z.literal(ChefConnectionMethod.UserKey), + credentials: ChefConnectionUserKeyCredentialsSchema +}); + +export const SanitizedChefConnectionSchema = z.discriminatedUnion("method", [ + BaseChefConnectionSchema.extend({ + method: z.literal(ChefConnectionMethod.UserKey), + credentials: ChefConnectionUserKeyCredentialsSchema.pick({ serverUrl: true, orgName: true, userName: true }) + }) +]); + +export const ValidateChefConnectionCredentialsSchema = z.discriminatedUnion("method", [ + z.object({ + method: z.literal(ChefConnectionMethod.UserKey).describe(AppConnections.CREATE(AppConnection.Chef).method), + credentials: ChefConnectionUserKeyCredentialsSchema.describe(AppConnections.CREATE(AppConnection.Chef).credentials) + }) +]); + +export const CreateChefConnectionSchema = ValidateChefConnectionCredentialsSchema.and( + GenericCreateAppConnectionFieldsSchema(AppConnection.Chef) +); + +export const UpdateChefConnectionSchema = z + .object({ + credentials: ChefConnectionUserKeyCredentialsSchema.optional().describe( + AppConnections.UPDATE(AppConnection.Chef).credentials + ) + }) + .and(GenericUpdateAppConnectionFieldsSchema(AppConnection.Chef)); + +export const ChefConnectionListItemSchema = z.object({ + name: z.literal("Chef"), + app: z.literal(AppConnection.Chef), + methods: z.nativeEnum(ChefConnectionMethod).array() +}); diff --git a/backend/src/services/app-connection/chef/chef-connection-service.ts b/backend/src/services/app-connection/chef/chef-connection-service.ts new file mode 100644 index 000000000..8357a8e4d --- /dev/null +++ b/backend/src/services/app-connection/chef/chef-connection-service.ts @@ -0,0 +1,40 @@ +import { ForbiddenRequestError } from "@app/lib/errors"; +// import { logger } from "@app/lib/logger"; +import { OrgServiceActor } from "@app/lib/types"; + +import { AppConnection } from "../app-connection-enums"; +import { listChefDataBagItems, listChefDataBags } from "./chef-connection-fns"; +import { TChefConnection } from "./chef-connection-types"; + +type TGetAppConnectionFunc = ( + app: AppConnection, + connectionId: string, + actor: OrgServiceActor +) => Promise; + +export const chefConnectionService = (getAppConnection: TGetAppConnectionFunc) => { + const listDataBags = async (appConnectionId: string, actor: OrgServiceActor) => { + const appConnection = await getAppConnection(AppConnection.Chef, appConnectionId, actor); + + if (!appConnection) { + throw new ForbiddenRequestError({ message: "App connection not found" }); + } + + return listChefDataBags(appConnection); + }; + + const listDataBagItems = async (appConnectionId: string, dataBagName: string, actor: OrgServiceActor) => { + const appConnection = await getAppConnection(AppConnection.Chef, appConnectionId, actor); + + if (!appConnection) { + throw new ForbiddenRequestError({ message: "App connection not found" }); + } + + return listChefDataBagItems(appConnection, dataBagName); + }; + + return { + listDataBags, + listDataBagItems + }; +}; diff --git a/backend/src/services/app-connection/chef/chef-connection-types.ts b/backend/src/services/app-connection/chef/chef-connection-types.ts new file mode 100644 index 000000000..7996026af --- /dev/null +++ b/backend/src/services/app-connection/chef/chef-connection-types.ts @@ -0,0 +1,32 @@ +import z from "zod"; + +import { DiscriminativePick } from "@app/lib/types"; + +import { AppConnection } from "../app-connection-enums"; +import { + ChefConnectionSchema, + CreateChefConnectionSchema, + ValidateChefConnectionCredentialsSchema +} from "./chef-connection-schemas"; + +export type TChefConnection = z.infer; + +export type TChefConnectionInput = z.infer & { + app: AppConnection.Chef; +}; + +export type TValidateChefConnectionCredentialsSchema = typeof ValidateChefConnectionCredentialsSchema; + +export type TChefConnectionConfig = DiscriminativePick & { + orgId: string; +}; + +export type TChefDataBag = { + name: string; + id: string; +}; + +export type TChefDataBagItem = { + name: string; + id: string; +}; diff --git a/backend/src/services/app-connection/chef/index.ts b/backend/src/services/app-connection/chef/index.ts new file mode 100644 index 000000000..e02479d0e --- /dev/null +++ b/backend/src/services/app-connection/chef/index.ts @@ -0,0 +1,4 @@ +export * from "./chef-connection-enums"; +export * from "./chef-connection-fns"; +export * from "./chef-connection-schemas"; +export * from "./chef-connection-types"; diff --git a/backend/src/services/integration-auth/integration-list.ts b/backend/src/services/integration-auth/integration-list.ts index 0608bbd4b..e4e1d3126 100644 --- a/backend/src/services/integration-auth/integration-list.ts +++ b/backend/src/services/integration-auth/integration-list.ts @@ -104,7 +104,8 @@ export enum IntegrationUrls { GCP_SERVICE_USAGE_URL = "https://serviceusage.googleapis.com", GCP_CLOUD_PLATFORM_SCOPE = "https://www.googleapis.com/auth/cloud-platform", - GITHUB_USER_INSTALLATIONS = "https://api.github.com/user/installations" + GITHUB_USER_INSTALLATIONS = "https://api.github.com/user/installations", + CHEF_API_URL = "https://api.chef.io" } export const getIntegrationOptions = async () => { diff --git a/frontend/public/images/integrations/Chef.png b/frontend/public/images/integrations/Chef.png new file mode 100644 index 000000000..8d8886c21 Binary files /dev/null and b/frontend/public/images/integrations/Chef.png differ diff --git a/frontend/src/helpers/appConnections.ts b/frontend/src/helpers/appConnections.ts index 9ee03c3c0..aa25e2f96 100644 --- a/frontend/src/helpers/appConnections.ts +++ b/frontend/src/helpers/appConnections.ts @@ -54,6 +54,7 @@ import { OCIConnectionMethod } from "@app/hooks/api/appConnections/types/oci-con import { RailwayConnectionMethod } from "@app/hooks/api/appConnections/types/railway-connection"; import { RenderConnectionMethod } from "@app/hooks/api/appConnections/types/render-connection"; import { SupabaseConnectionMethod } from "@app/hooks/api/appConnections/types/supabase-connection"; +import { ChefConnectionMethod } from "@app/hooks/api/appConnections/types/chef-connection"; export const APP_CONNECTION_MAP: Record< AppConnection, @@ -127,7 +128,8 @@ export const APP_CONNECTION_MAP: Record< name: "Laravel Forge", image: "Laravel Forge.png", size: 65 - } + }, + [AppConnection.Chef]: { name: "Chef", image: "Chef.png" } }; export const getAppConnectionMethodDetails = (method: TAppConnection["method"]) => { @@ -199,6 +201,8 @@ export const getAppConnectionMethodDetails = (method: TAppConnection["method"]) case RenderConnectionMethod.ApiKey: case ChecklyConnectionMethod.ApiKey: return { name: "API Key", icon: faKey }; + case ChefConnectionMethod.UserKey: + return { name: "User Key", icon: faKey }; case AzureClientSecretsConnectionMethod.ClientSecret: case AzureAppConfigurationConnectionMethod.ClientSecret: case AzureKeyVaultConnectionMethod.ClientSecret: diff --git a/frontend/src/hooks/api/appConnections/enums.ts b/frontend/src/hooks/api/appConnections/enums.ts index 66fed8a5d..c58fb9194 100644 --- a/frontend/src/hooks/api/appConnections/enums.ts +++ b/frontend/src/hooks/api/appConnections/enums.ts @@ -38,5 +38,6 @@ export enum AppConnection { Netlify = "netlify", Okta = "okta", Redis = "redis", - LaravelForge = "laravel-forge" + LaravelForge = "laravel-forge", + Chef = "chef" } diff --git a/frontend/src/hooks/api/appConnections/types/app-options.ts b/frontend/src/hooks/api/appConnections/types/app-options.ts index 797e7a7c6..edf26d2e1 100644 --- a/frontend/src/hooks/api/appConnections/types/app-options.ts +++ b/frontend/src/hooks/api/appConnections/types/app-options.ts @@ -148,6 +148,10 @@ export type TChecklyConnectionOption = TAppConnectionOptionBase & { app: AppConnection.Checkly; }; +export type TChefConnectionOption = TAppConnectionOptionBase & { + app: AppConnection.Chef; +}; + export type TSupabaseConnectionOption = TAppConnectionOptionBase & { app: AppConnection.Supabase; }; @@ -215,7 +219,8 @@ export type TAppConnectionOption = | TNetlifyConnectionOption | TOktaConnectionOption | TAzureAdCsConnectionOption - | TLaravelForgeConnectionOption; + | TLaravelForgeConnectionOption + | TChefConnectionOption; export type TAppConnectionOptionMap = { [AppConnection.AWS]: TAwsConnectionOption; @@ -258,4 +263,5 @@ export type TAppConnectionOptionMap = { [AppConnection.AzureADCS]: TAzureAdCsConnectionOption; [AppConnection.Redis]: TRedisConnectionOption; [AppConnection.LaravelForge]: TLaravelForgeConnectionOption; + [AppConnection.Chef]: TChefConnectionOption; }; diff --git a/frontend/src/hooks/api/appConnections/types/chef-connection.ts b/frontend/src/hooks/api/appConnections/types/chef-connection.ts new file mode 100644 index 000000000..371d42199 --- /dev/null +++ b/frontend/src/hooks/api/appConnections/types/chef-connection.ts @@ -0,0 +1,16 @@ +import { AppConnection } from "@app/hooks/api/appConnections/enums"; +import { TRootAppConnection } from "@app/hooks/api/appConnections/types/root-connection"; + +export enum ChefConnectionMethod { + UserKey = "user-key" +} + +export type TChefConnection = TRootAppConnection & { app: AppConnection.Chef } & { + method: ChefConnectionMethod.UserKey; + credentials: { + instanceUrl?: string; + orgName: string; + userName: string; + privateKey: string; + }; +}; diff --git a/frontend/src/hooks/api/appConnections/types/index.ts b/frontend/src/hooks/api/appConnections/types/index.ts index fd840d5de..2664a462c 100644 --- a/frontend/src/hooks/api/appConnections/types/index.ts +++ b/frontend/src/hooks/api/appConnections/types/index.ts @@ -40,6 +40,7 @@ import { TTerraformCloudConnection } from "./terraform-cloud-connection"; import { TVercelConnection } from "./vercel-connection"; import { TWindmillConnection } from "./windmill-connection"; import { TZabbixConnection } from "./zabbix-connection"; +import { TChefConnection } from "./chef-connection"; export * from "./1password-connection"; export * from "./auth0-connection"; @@ -79,6 +80,7 @@ export * from "./terraform-cloud-connection"; export * from "./vercel-connection"; export * from "./windmill-connection"; export * from "./zabbix-connection"; +export * from "./chef-connection"; export type TAppConnection = | TAwsConnection @@ -120,7 +122,8 @@ export type TAppConnection = | TDigitalOceanConnection | TNetlifyConnection | TOktaConnection - | TRedisConnection; + | TRedisConnection + | TChefConnection; export type TAvailableAppConnection = Pick; diff --git a/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/AppConnectionForm.tsx b/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/AppConnectionForm.tsx index c4115a4b7..eee0cd84f 100644 --- a/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/AppConnectionForm.tsx +++ b/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/AppConnectionForm.tsx @@ -20,6 +20,7 @@ import { AzureKeyVaultConnectionForm } from "./AzureKeyVaultConnectionForm"; import { BitbucketConnectionForm } from "./BitbucketConnectionForm"; import { CamundaConnectionForm } from "./CamundaConnectionForm"; import { ChecklyConnectionForm } from "./ChecklyConnectionForm"; +import { ChefConnectionForm } from "./ChefConnectionForm"; import { CloudflareConnectionForm } from "./CloudflareConnectionForm"; import { DatabricksConnectionForm } from "./DatabricksConnectionForm"; import { DigitalOceanConnectionForm } from "./DigitalOceanConnectionForm"; @@ -163,6 +164,8 @@ const CreateForm = ({ app, onComplete, projectId }: CreateFormProps) => { return ; case AppConnection.Checkly: return ; + case AppConnection.Chef: + return ; case AppConnection.Supabase: return ; case AppConnection.DigitalOcean: @@ -326,6 +329,8 @@ const UpdateForm = ({ appConnection, onComplete }: UpdateFormProps) => { return ; case AppConnection.Checkly: return ; + case AppConnection.Chef: + return ; case AppConnection.Supabase: return ; case AppConnection.DigitalOcean: diff --git a/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/ChefConnectionForm.tsx b/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/ChefConnectionForm.tsx new file mode 100644 index 000000000..d56a7523b --- /dev/null +++ b/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/ChefConnectionForm.tsx @@ -0,0 +1,197 @@ +import { Controller, FormProvider, useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; + +import { + Button, + FormControl, + Input, + ModalClose, + SecretInput, + Select, + SelectItem +} from "@app/components/v2"; +import { APP_CONNECTION_MAP, getAppConnectionMethodDetails } from "@app/helpers/appConnections"; +import { ChefConnectionMethod, TChefConnection } from "@app/hooks/api/appConnections"; +import { AppConnection } from "@app/hooks/api/appConnections/enums"; + +import { + genericAppConnectionFieldsSchema, + GenericAppConnectionsFields +} from "./GenericAppConnectionFields"; + +type Props = { + appConnection?: TChefConnection; + onSubmit: (formData: FormData) => Promise; +}; + +const rootSchema = genericAppConnectionFieldsSchema.extend({ + app: z.literal(AppConnection.Chef) +}); + +const formSchema = z.discriminatedUnion("method", [ + rootSchema.extend({ + method: z.literal(ChefConnectionMethod.UserKey), + credentials: z.object({ + serverUrl: z.string().trim().url("Valid Chef Server URL required").optional(), + orgName: z.string().trim().min(1, "Organization name required"), + userName: z.string().trim().min(1, "User name required"), + privateKey: z.string().trim().min(1, "Private key required") + }) + }) +]); + +type FormData = z.infer; + +export const ChefConnectionForm = ({ appConnection, onSubmit }: Props) => { + const isUpdate = Boolean(appConnection); + + const form = useForm({ + resolver: zodResolver(formSchema), + defaultValues: appConnection ?? { + app: AppConnection.Chef, + method: ChefConnectionMethod.UserKey + } + }); + + const { + handleSubmit, + control, + formState: { isSubmitting, isDirty } + } = form; + + console.log("form", form.formState.errors, form.getValues()); + + return ( + +
+ {!isUpdate && } + ( + + onChange(e.target.value)} + /> + + )} + /> + ( + + + + )} + /> + + ( + + onChange(e.target.value)} + /> + + )} + /> + ( + + onChange(e.target.value)} + /> + + )} + /> + ( + + onChange(e.target.value)} + /> + + )} + /> +
+ + + + +
+ +
+ ); +};