diff --git a/.infisicalignore b/.infisicalignore index 8cf00ac26..d705c0d66 100644 --- a/.infisicalignore +++ b/.infisicalignore @@ -45,3 +45,4 @@ cli/detect/config/gitleaks.toml:gcp-api-key:582 .github/workflows/helm-release-infisical-core.yml:generic-api-key:48 .github/workflows/helm-release-infisical-core.yml:generic-api-key:47 backend/src/services/smtp/smtp-service.ts:generic-api-key:79 +frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/CloudflarePagesSyncFields.tsx:cloudflare-api-key:7 diff --git a/backend/src/lib/api-docs/constants.ts b/backend/src/lib/api-docs/constants.ts index 67b70079b..b4892a209 100644 --- a/backend/src/lib/api-docs/constants.ts +++ b/backend/src/lib/api-docs/constants.ts @@ -2401,6 +2401,10 @@ export const SecretSyncs = { }, FLYIO: { appId: "The ID of the Fly.io app to sync secrets to." + }, + CLOUDFLARE_PAGES: { + projectName: "The name of the Cloudflare Pages project to sync secrets to.", + environment: "The environment of the Cloudflare Pages project to sync secrets to." } } }; 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 e474859c3..dcf8bf2ce 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 @@ -80,6 +80,10 @@ import { WindmillConnectionListItemSchema } from "@app/services/app-connection/windmill"; import { AuthMode } from "@app/services/auth/auth-type"; +import { + CloudflareConnectionListItemSchema, + SanitizedCloudflareConnectionSchema +} from "@app/services/app-connection/cloudflare/cloudflare-connection-schema"; // can't use discriminated due to multiple schemas for certain apps const SanitizedAppConnectionSchema = z.union([ @@ -109,7 +113,8 @@ const SanitizedAppConnectionSchema = z.union([ ...SanitizedOnePassConnectionSchema.options, ...SanitizedHerokuConnectionSchema.options, ...SanitizedRenderConnectionSchema.options, - ...SanitizedFlyioConnectionSchema.options + ...SanitizedFlyioConnectionSchema.options, + ...SanitizedCloudflareConnectionSchema.options ]); const AppConnectionOptionsSchema = z.discriminatedUnion("app", [ @@ -139,7 +144,8 @@ const AppConnectionOptionsSchema = z.discriminatedUnion("app", [ OnePassConnectionListItemSchema, HerokuConnectionListItemSchema, RenderConnectionListItemSchema, - FlyioConnectionListItemSchema + FlyioConnectionListItemSchema, + CloudflareConnectionListItemSchema ]); export const registerAppConnectionRouter = async (server: FastifyZodProvider) => { diff --git a/backend/src/server/routes/v1/app-connection-routers/cloudflare-connection-router.ts b/backend/src/server/routes/v1/app-connection-routers/cloudflare-connection-router.ts new file mode 100644 index 000000000..bd3507a7d --- /dev/null +++ b/backend/src/server/routes/v1/app-connection-routers/cloudflare-connection-router.ts @@ -0,0 +1,53 @@ +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 { + CreateCloudflareConnectionSchema, + SanitizedCloudflareConnectionSchema, + UpdateCloudflareConnectionSchema +} from "@app/services/app-connection/cloudflare/cloudflare-connection-schema"; +import { AuthMode } from "@app/services/auth/auth-type"; + +import { registerAppConnectionEndpoints } from "./app-connection-endpoints"; + +export const registerCloudflareConnectionRouter = async (server: FastifyZodProvider) => { + registerAppConnectionEndpoints({ + app: AppConnection.Cloudflare, + server, + sanitizedResponseSchema: SanitizedCloudflareConnectionSchema, + createSchema: CreateCloudflareConnectionSchema, + updateSchema: UpdateCloudflareConnectionSchema + }); + + // The below endpoints are not exposed and for Infisical App use + server.route({ + method: "GET", + url: `/:connectionId/cloudflare-pages-projects`, + 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 projects = await server.services.appConnection.cloudflare.listPagesProjects(connectionId, req.permission); + + return projects; + } + }); +}; 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 da1857ac5..d8f2b05a2 100644 --- a/backend/src/server/routes/v1/app-connection-routers/index.ts +++ b/backend/src/server/routes/v1/app-connection-routers/index.ts @@ -27,6 +27,7 @@ import { registerTeamCityConnectionRouter } from "./teamcity-connection-router"; import { registerTerraformCloudConnectionRouter } from "./terraform-cloud-router"; import { registerVercelConnectionRouter } from "./vercel-connection-router"; import { registerWindmillConnectionRouter } from "./windmill-connection-router"; +import { registerCloudflareConnectionRouter } from "./cloudflare-connection-router"; export * from "./app-connection-router"; @@ -58,5 +59,6 @@ export const APP_CONNECTION_REGISTER_ROUTER_MAP: Record + registerSyncSecretsEndpoints({ + destination: SecretSync.CloudflarePages, + server, + responseSchema: CloudflarePagesSyncSchema, + createSchema: CreateCloudflarePagesSyncSchema, + updateSchema: UpdateCloudflarePagesSyncSchema + }); diff --git a/backend/src/server/routes/v1/secret-sync-routers/index.ts b/backend/src/server/routes/v1/secret-sync-routers/index.ts index 989d289ec..b55c63a92 100644 --- a/backend/src/server/routes/v1/secret-sync-routers/index.ts +++ b/backend/src/server/routes/v1/secret-sync-routers/index.ts @@ -8,6 +8,7 @@ import { registerAzureAppConfigurationSyncRouter } from "./azure-app-configurati import { registerAzureDevOpsSyncRouter } from "./azure-devops-sync-router"; import { registerAzureKeyVaultSyncRouter } from "./azure-key-vault-sync-router"; import { registerCamundaSyncRouter } from "./camunda-sync-router"; +import { registerCloudflarePagesSyncRouter } from "./cloudflare-pages-sync-router"; import { registerDatabricksSyncRouter } from "./databricks-sync-router"; import { registerFlyioSyncRouter } from "./flyio-sync-router"; import { registerGcpSyncRouter } from "./gcp-sync-router"; @@ -43,5 +44,6 @@ export const SECRET_SYNC_REGISTER_ROUTER_MAP: Record { diff --git a/backend/src/services/app-connection/app-connection-enums.ts b/backend/src/services/app-connection/app-connection-enums.ts index e0b7a61f1..dca473c73 100644 --- a/backend/src/services/app-connection/app-connection-enums.ts +++ b/backend/src/services/app-connection/app-connection-enums.ts @@ -25,7 +25,8 @@ export enum AppConnection { OnePass = "1password", Heroku = "heroku", Render = "render", - Flyio = "flyio" + Flyio = "flyio", + Cloudflare = "cloudflare" } export enum AWSRegion { diff --git a/backend/src/services/app-connection/app-connection-fns.ts b/backend/src/services/app-connection/app-connection-fns.ts index 66027e24b..ab8c717a5 100644 --- a/backend/src/services/app-connection/app-connection-fns.ts +++ b/backend/src/services/app-connection/app-connection-fns.ts @@ -99,6 +99,11 @@ import { validateWindmillConnectionCredentials, WindmillConnectionMethod } from "./windmill"; +import { + getCloudflareConnectionListItem, + validateCloudflareConnectionCredentials +} from "./cloudflare/cloudflare-connection-fns"; +import { CloudflareConnectionMethod } from "./cloudflare/cloudflare-connection-enum"; export const listAppConnectionOptions = () => { return [ @@ -128,7 +133,8 @@ export const listAppConnectionOptions = () => { getOnePassConnectionListItem(), getHerokuConnectionListItem(), getRenderConnectionListItem(), - getFlyioConnectionListItem() + getFlyioConnectionListItem(), + getCloudflareConnectionListItem() ].sort((a, b) => a.name.localeCompare(b.name)); }; @@ -206,7 +212,8 @@ export const validateAppConnectionCredentials = async ( [AppConnection.OnePass]: validateOnePassConnectionCredentials as TAppConnectionCredentialsValidator, [AppConnection.Heroku]: validateHerokuConnectionCredentials as TAppConnectionCredentialsValidator, [AppConnection.Render]: validateRenderConnectionCredentials as TAppConnectionCredentialsValidator, - [AppConnection.Flyio]: validateFlyioConnectionCredentials as TAppConnectionCredentialsValidator + [AppConnection.Flyio]: validateFlyioConnectionCredentials as TAppConnectionCredentialsValidator, + [AppConnection.Cloudflare]: validateCloudflareConnectionCredentials as TAppConnectionCredentialsValidator }; return VALIDATE_APP_CONNECTION_CREDENTIALS_MAP[appConnection.app](appConnection); @@ -241,6 +248,7 @@ export const getAppConnectionMethodName = (method: TAppConnection["method"]) => case TerraformCloudConnectionMethod.ApiToken: case VercelConnectionMethod.ApiToken: case OnePassConnectionMethod.ApiToken: + case CloudflareConnectionMethod.APIToken: return "API Token"; case PostgresConnectionMethod.UsernameAndPassword: case MsSqlConnectionMethod.UsernameAndPassword: @@ -318,7 +326,8 @@ export const TRANSITION_CONNECTION_CREDENTIALS_TO_PLATFORM: Record< [AppConnection.OnePass]: platformManagedCredentialsNotSupported, [AppConnection.Heroku]: platformManagedCredentialsNotSupported, [AppConnection.Render]: platformManagedCredentialsNotSupported, - [AppConnection.Flyio]: platformManagedCredentialsNotSupported + [AppConnection.Flyio]: platformManagedCredentialsNotSupported, + [AppConnection.Cloudflare]: 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 57725d1e1..6e735af9f 100644 --- a/backend/src/services/app-connection/app-connection-maps.ts +++ b/backend/src/services/app-connection/app-connection-maps.ts @@ -27,7 +27,8 @@ export const APP_CONNECTION_NAME_MAP: Record = { [AppConnection.OnePass]: "1Password", [AppConnection.Heroku]: "Heroku", [AppConnection.Render]: "Render", - [AppConnection.Flyio]: "Fly.io" + [AppConnection.Flyio]: "Fly.io", + [AppConnection.Cloudflare]: "Cloudflare" }; export const APP_CONNECTION_PLAN_MAP: Record = { @@ -57,5 +58,6 @@ export const APP_CONNECTION_PLAN_MAP: Record>>; @@ -216,6 +223,7 @@ export type TAppConnectionInput = { id: string } & ( | THerokuConnectionInput | TRenderConnectionInput | TFlyioConnectionInput + | TCloudflareConnectionInput ); export type TSqlConnectionInput = @@ -257,7 +265,8 @@ export type TAppConnectionConfig = | TOnePassConnectionConfig | THerokuConnectionConfig | TRenderConnectionConfig - | TFlyioConnectionConfig; + | TFlyioConnectionConfig + | TCloudflareConnectionConfig; export type TValidateAppConnectionCredentialsSchema = | TValidateAwsConnectionCredentialsSchema @@ -286,7 +295,8 @@ export type TValidateAppConnectionCredentialsSchema = | TValidateOnePassConnectionCredentialsSchema | TValidateHerokuConnectionCredentialsSchema | TValidateRenderConnectionCredentialsSchema - | TValidateFlyioConnectionCredentialsSchema; + | TValidateFlyioConnectionCredentialsSchema + | TValidateCloudflareConnectionCredentialsSchema; export type TListAwsConnectionKmsKeys = { connectionId: string; diff --git a/backend/src/services/app-connection/cloudflare/cloudflare-connection-enum.ts b/backend/src/services/app-connection/cloudflare/cloudflare-connection-enum.ts new file mode 100644 index 000000000..2381524b9 --- /dev/null +++ b/backend/src/services/app-connection/cloudflare/cloudflare-connection-enum.ts @@ -0,0 +1,3 @@ +export enum CloudflareConnectionMethod { + APIToken = "api-token" +} diff --git a/backend/src/services/app-connection/cloudflare/cloudflare-connection-fns.ts b/backend/src/services/app-connection/cloudflare/cloudflare-connection-fns.ts new file mode 100644 index 000000000..28ad44de0 --- /dev/null +++ b/backend/src/services/app-connection/cloudflare/cloudflare-connection-fns.ts @@ -0,0 +1,75 @@ +import { AxiosError } from "axios"; + +import { request } from "@app/lib/config/request"; +import { BadRequestError } from "@app/lib/errors"; +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; +import { IntegrationUrls } from "@app/services/integration-auth/integration-list"; + +import { CloudflareConnectionMethod } from "./cloudflare-connection-enum"; +import { + TCloudflareConnection, + TCloudflareConnectionConfig, + TCloudflarePagesProject +} from "./cloudflare-connection-types"; + +export const getCloudflareConnectionListItem = () => { + return { + name: "Cloudflare" as const, + app: AppConnection.Cloudflare as const, + methods: Object.values(CloudflareConnectionMethod) as [CloudflareConnectionMethod.APIToken] + }; +}; + +export const listCloudflarePagesProjects = async ( + appConnection: TCloudflareConnection +): Promise => { + const { + credentials: { apiToken, accountId } + } = appConnection; + + const { data } = await request.get<{ result: { name: string; id: string }[] }>( + `${IntegrationUrls.CLOUDFLARE_API_URL}/client/v4/accounts/${accountId}/pages/projects`, + { + headers: { + Authorization: `Bearer ${apiToken}`, + Accept: "application/json" + } + } + ); + + return data.result.map((a) => ({ + name: a.name, + id: a.id + })); +}; + +export const validateCloudflareConnectionCredentials = async (config: TCloudflareConnectionConfig) => { + const { apiToken, accountId } = config.credentials; + + try { + const resp = await request.get(`${IntegrationUrls.CLOUDFLARE_API_URL}/client/v4/accounts/${accountId}`, { + headers: { + Authorization: `Bearer ${apiToken}`, + Accept: "application/json" + } + }); + + if (resp.data === null) { + throw new BadRequestError({ + message: "Unable to validate connection: Invalid API token provided." + }); + } + } catch (error: unknown) { + if (error instanceof AxiosError) { + throw new BadRequestError({ + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + message: `Failed to validate credentials: ${error.response?.data?.errors?.[0]?.message || error.message || "Unknown error"}` + }); + } + throw new BadRequestError({ + message: "Unable to validate connection: verify credentials" + }); + } + + return config.credentials; +}; diff --git a/backend/src/services/app-connection/cloudflare/cloudflare-connection-schema.ts b/backend/src/services/app-connection/cloudflare/cloudflare-connection-schema.ts new file mode 100644 index 000000000..64dee9dd5 --- /dev/null +++ b/backend/src/services/app-connection/cloudflare/cloudflare-connection-schema.ts @@ -0,0 +1,74 @@ +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 { CloudflareConnectionMethod } from "./cloudflare-connection-enum"; +import { CharacterType, characterValidator } from "@app/lib/validator/validate-string"; + +const accountIdCharacterValidator = characterValidator([ + CharacterType.AlphaNumeric, + CharacterType.Underscore, + CharacterType.Hyphen +]); + +export const CloudflareConnectionApiTokenCredentialsSchema = z.object({ + accountId: z + .string() + .trim() + .min(1, "Account ID required") + .max(256, "Account ID cannot exceed 256 characters") + .refine( + (val) => accountIdCharacterValidator(val), + "Account ID can only contain alphanumeric characters, underscores, and hyphens" + ), + apiToken: z.string().trim().min(1, "API token required").max(256, "API token cannot exceed 256 characters") +}); + +const BaseCloudflareConnectionSchema = BaseAppConnectionSchema.extend({ app: z.literal(AppConnection.Cloudflare) }); + +export const CloudflareConnectionSchema = BaseCloudflareConnectionSchema.extend({ + method: z.literal(CloudflareConnectionMethod.APIToken), + credentials: CloudflareConnectionApiTokenCredentialsSchema +}); + +export const SanitizedCloudflareConnectionSchema = z.discriminatedUnion("method", [ + BaseCloudflareConnectionSchema.extend({ + method: z.literal(CloudflareConnectionMethod.APIToken), + credentials: CloudflareConnectionApiTokenCredentialsSchema.pick({ accountId: true }) + }) +]); + +export const ValidateCloudflareConnectionCredentialsSchema = z.discriminatedUnion("method", [ + z.object({ + method: z + .literal(CloudflareConnectionMethod.APIToken) + .describe(AppConnections.CREATE(AppConnection.Cloudflare).method), + credentials: CloudflareConnectionApiTokenCredentialsSchema.describe( + AppConnections.CREATE(AppConnection.Cloudflare).credentials + ) + }) +]); + +export const CreateCloudflareConnectionSchema = ValidateCloudflareConnectionCredentialsSchema.and( + GenericCreateAppConnectionFieldsSchema(AppConnection.Cloudflare) +); + +export const UpdateCloudflareConnectionSchema = z + .object({ + credentials: CloudflareConnectionApiTokenCredentialsSchema.optional().describe( + AppConnections.UPDATE(AppConnection.Cloudflare).credentials + ) + }) + .and(GenericUpdateAppConnectionFieldsSchema(AppConnection.Cloudflare)); + +export const CloudflareConnectionListItemSchema = z.object({ + name: z.literal("Cloudflare"), + app: z.literal(AppConnection.Cloudflare), + methods: z.nativeEnum(CloudflareConnectionMethod).array() +}); diff --git a/backend/src/services/app-connection/cloudflare/cloudflare-connection-service.ts b/backend/src/services/app-connection/cloudflare/cloudflare-connection-service.ts new file mode 100644 index 000000000..2d1f38786 --- /dev/null +++ b/backend/src/services/app-connection/cloudflare/cloudflare-connection-service.ts @@ -0,0 +1,30 @@ +import { logger } from "@app/lib/logger"; +import { OrgServiceActor } from "@app/lib/types"; + +import { AppConnection } from "../app-connection-enums"; +import { listCloudflarePagesProjects } from "./cloudflare-connection-fns"; +import { TCloudflareConnection } from "./cloudflare-connection-types"; + +type TGetAppConnectionFunc = ( + app: AppConnection, + connectionId: string, + actor: OrgServiceActor +) => Promise; + +export const cloudflareConnectionService = (getAppConnection: TGetAppConnectionFunc) => { + const listPagesProjects = async (connectionId: string, actor: OrgServiceActor) => { + const appConnection = await getAppConnection(AppConnection.Cloudflare, connectionId, actor); + try { + const projects = await listCloudflarePagesProjects(appConnection); + + return projects; + } catch (error) { + logger.error(error, "Failed to list Cloudflare Pages projects for Cloudflare connection"); + return []; + } + }; + + return { + listPagesProjects + }; +}; diff --git a/backend/src/services/app-connection/cloudflare/cloudflare-connection-types.ts b/backend/src/services/app-connection/cloudflare/cloudflare-connection-types.ts new file mode 100644 index 000000000..6b2ee0d04 --- /dev/null +++ b/backend/src/services/app-connection/cloudflare/cloudflare-connection-types.ts @@ -0,0 +1,30 @@ +import z from "zod"; + +import { DiscriminativePick } from "@app/lib/types"; + +import { AppConnection } from "../app-connection-enums"; +import { + CloudflareConnectionSchema, + CreateCloudflareConnectionSchema, + ValidateCloudflareConnectionCredentialsSchema +} from "./cloudflare-connection-schema"; + +export type TCloudflareConnection = z.infer; + +export type TCloudflareConnectionInput = z.infer & { + app: AppConnection.Cloudflare; +}; + +export type TValidateCloudflareConnectionCredentialsSchema = typeof ValidateCloudflareConnectionCredentialsSchema; + +export type TCloudflareConnectionConfig = DiscriminativePick< + TCloudflareConnectionInput, + "method" | "app" | "credentials" +> & { + orgId: string; +}; + +export type TCloudflarePagesProject = { + id: string; + name: string; +}; diff --git a/backend/src/services/integration-auth/integration-list.ts b/backend/src/services/integration-auth/integration-list.ts index e8ef72f7a..9b9841f1f 100644 --- a/backend/src/services/integration-auth/integration-list.ts +++ b/backend/src/services/integration-auth/integration-list.ts @@ -84,6 +84,8 @@ export enum IntegrationUrls { QOVERY_API_URL = "https://api.qovery.com", TERRAFORM_CLOUD_API_URL = "https://app.terraform.io", CLOUDFLARE_PAGES_API_URL = "https://api.cloudflare.com", + // eslint-disable-next-line @typescript-eslint/no-duplicate-enum-values + CLOUDFLARE_API_URL = "https://api.cloudflare.com", // eslint-disable-next-line CLOUDFLARE_WORKERS_API_URL = "https://api.cloudflare.com", BITBUCKET_API_URL = "https://api.bitbucket.org", diff --git a/backend/src/services/secret-sync/cloudflare-pages/cloudflare-pages-constants.ts b/backend/src/services/secret-sync/cloudflare-pages/cloudflare-pages-constants.ts new file mode 100644 index 000000000..4ec02074c --- /dev/null +++ b/backend/src/services/secret-sync/cloudflare-pages/cloudflare-pages-constants.ts @@ -0,0 +1,10 @@ +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; +import { SecretSync } from "@app/services/secret-sync/secret-sync-enums"; +import { TSecretSyncListItem } from "@app/services/secret-sync/secret-sync-types"; + +export const CLOUDFLARE_PAGES_SYNC_LIST_OPTION: TSecretSyncListItem = { + name: "Cloudflare Pages", + destination: SecretSync.CloudflarePages, + connection: AppConnection.Cloudflare, + canImportSecrets: false +}; diff --git a/backend/src/services/secret-sync/cloudflare-pages/cloudflare-pages-fns.ts b/backend/src/services/secret-sync/cloudflare-pages/cloudflare-pages-fns.ts new file mode 100644 index 000000000..e2754da7a --- /dev/null +++ b/backend/src/services/secret-sync/cloudflare-pages/cloudflare-pages-fns.ts @@ -0,0 +1,138 @@ +import { request } from "@app/lib/config/request"; +import { IntegrationUrls } from "@app/services/integration-auth/integration-list"; +import { matchesSchema } from "@app/services/secret-sync/secret-sync-fns"; +import { TSecretMap } from "@app/services/secret-sync/secret-sync-types"; + +import { SECRET_SYNC_NAME_MAP } from "../secret-sync-maps"; +import { TCloudflarePagesSyncWithCredentials } from "./cloudflare-pages-types"; + +const getProjectEnvironmentSecrets = async (secretSync: TCloudflarePagesSyncWithCredentials) => { + const { + destinationConfig, + connection: { + credentials: { apiToken, accountId } + } + } = secretSync; + + const secrets = ( + await request.get<{ + result: { + deployment_configs: Record< + string, + { + env_vars: Record; + } + >; + }; + }>( + `${IntegrationUrls.CLOUDFLARE_PAGES_API_URL}/client/v4/accounts/${accountId}/pages/projects/${destinationConfig.projectName}`, + { + headers: { + Authorization: `Bearer ${apiToken}`, + Accept: "application/json" + } + } + ) + ).data.result.deployment_configs[destinationConfig.environment].env_vars; + + return Object.entries(secrets ?? {}).map(([key, envVar]) => ({ + key, + value: envVar.value + })); +}; + +export const CloudflarePagesSyncFns = { + syncSecrets: async (secretSync: TCloudflarePagesSyncWithCredentials, secretMap: TSecretMap) => { + const { + destinationConfig, + connection: { + credentials: { apiToken, accountId } + } + } = secretSync; + + // Create/update secret entries + let secretEntries: [string, object | null][] = Object.entries(secretMap).map(([key, val]) => [ + key, + { type: "secret_text", value: val.value } + ]); + + // Handle deletions if not disabled + if (!secretSync.syncOptions.disableSecretDeletion) { + const existingSecrets = await getProjectEnvironmentSecrets(secretSync); + const toDeleteKeys = existingSecrets + .filter( + (secret) => + matchesSchema(secret.key, secretSync.environment?.slug || "", secretSync.syncOptions.keySchema) && + !secretMap[secret.key] + ) + .map((secret) => secret.key); + + const toDeleteEntries: [string, null][] = toDeleteKeys.map((key) => [key, null]); + secretEntries = [...secretEntries, ...toDeleteEntries]; + } + + const data = { + deployment_configs: { + [destinationConfig.environment]: { + env_vars: Object.fromEntries(secretEntries) + } + } + }; + + await request.patch( + `${IntegrationUrls.CLOUDFLARE_PAGES_API_URL}/client/v4/accounts/${accountId}/pages/projects/${destinationConfig.projectName}`, + data, + { + headers: { + Authorization: `Bearer ${apiToken}`, + Accept: "application/json" + } + } + ); + }, + + getSecrets: async (secretSync: TCloudflarePagesSyncWithCredentials): Promise => { + throw new Error(`${SECRET_SYNC_NAME_MAP[secretSync.destination]} does not support importing secrets.`); + }, + + removeSecrets: async (secretSync: TCloudflarePagesSyncWithCredentials, secretMap: TSecretMap) => { + const { + destinationConfig, + connection: { + credentials: { apiToken, accountId } + } + } = secretSync; + + const secrets = await getProjectEnvironmentSecrets(secretSync); + const toDeleteKeys = secrets + .filter( + (secret) => + matchesSchema(secret.key, secretSync.environment?.slug || "", secretSync.syncOptions.keySchema) && + secret.key in secretMap + ) + .map((secret) => secret.key); + + if (toDeleteKeys.length === 0) return; + + const secretEntries: [string, null][] = toDeleteKeys.map((key) => [key, null]); + + const data = { + deployment_configs: { + [destinationConfig.environment]: { + env_vars: Object.fromEntries(secretEntries) + } + } + }; + + await request.patch( + `${IntegrationUrls.CLOUDFLARE_PAGES_API_URL}/client/v4/accounts/${accountId}/pages/projects/${destinationConfig.projectName}`, + data, + { + headers: { + Authorization: `Bearer ${apiToken}`, + Accept: "application/json" + } + } + ); + } +}; diff --git a/backend/src/services/secret-sync/cloudflare-pages/cloudflare-pages-schema.ts b/backend/src/services/secret-sync/cloudflare-pages/cloudflare-pages-schema.ts new file mode 100644 index 000000000..f0d814fb6 --- /dev/null +++ b/backend/src/services/secret-sync/cloudflare-pages/cloudflare-pages-schema.ts @@ -0,0 +1,53 @@ +import { z } from "zod"; + +import { SecretSyncs } from "@app/lib/api-docs"; +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; +import { SecretSync } from "@app/services/secret-sync/secret-sync-enums"; +import { + BaseSecretSyncSchema, + GenericCreateSecretSyncFieldsSchema, + GenericUpdateSecretSyncFieldsSchema +} from "@app/services/secret-sync/secret-sync-schemas"; +import { TSyncOptionsConfig } from "@app/services/secret-sync/secret-sync-types"; + +const CloudflarePagesSyncDestinationConfigSchema = z.object({ + projectName: z + .string() + .min(1, "Project name is required") + .describe(SecretSyncs.DESTINATION_CONFIG.CLOUDFLARE_PAGES.projectName), + environment: z + .string() + .min(1, "Environment is required") + .describe(SecretSyncs.DESTINATION_CONFIG.CLOUDFLARE_PAGES.environment) +}); + +const CloudflarePagesSyncOptionsConfig: TSyncOptionsConfig = { canImportSecrets: false }; + +export const CloudflarePagesSyncSchema = BaseSecretSyncSchema( + SecretSync.CloudflarePages, + CloudflarePagesSyncOptionsConfig +).extend({ + destination: z.literal(SecretSync.CloudflarePages), + destinationConfig: CloudflarePagesSyncDestinationConfigSchema +}); + +export const CreateCloudflarePagesSyncSchema = GenericCreateSecretSyncFieldsSchema( + SecretSync.CloudflarePages, + CloudflarePagesSyncOptionsConfig +).extend({ + destinationConfig: CloudflarePagesSyncDestinationConfigSchema +}); + +export const UpdateCloudflarePagesSyncSchema = GenericUpdateSecretSyncFieldsSchema( + SecretSync.CloudflarePages, + CloudflarePagesSyncOptionsConfig +).extend({ + destinationConfig: CloudflarePagesSyncDestinationConfigSchema.optional() +}); + +export const CloudflarePagesSyncListItemSchema = z.object({ + name: z.literal("Cloudflare Pages"), + connection: z.literal(AppConnection.Cloudflare), + destination: z.literal(SecretSync.CloudflarePages), + canImportSecrets: z.literal(false) +}); diff --git a/backend/src/services/secret-sync/cloudflare-pages/cloudflare-pages-types.ts b/backend/src/services/secret-sync/cloudflare-pages/cloudflare-pages-types.ts new file mode 100644 index 000000000..e87280206 --- /dev/null +++ b/backend/src/services/secret-sync/cloudflare-pages/cloudflare-pages-types.ts @@ -0,0 +1,19 @@ +import z from "zod"; + +import { TCloudflareConnection } from "@app/services/app-connection/cloudflare/cloudflare-connection-types"; + +import { + CloudflarePagesSyncListItemSchema, + CloudflarePagesSyncSchema, + CreateCloudflarePagesSyncSchema +} from "./cloudflare-pages-schema"; + +export type TCloudflarePagesSyncListItem = z.infer; + +export type TCloudflarePagesSync = z.infer; + +export type TCloudflarePagesSyncInput = z.infer; + +export type TCloudflarePagesSyncWithCredentials = TCloudflarePagesSync & { + connection: TCloudflareConnection; +}; diff --git a/backend/src/services/secret-sync/secret-sync-enums.ts b/backend/src/services/secret-sync/secret-sync-enums.ts index 04aaed0ce..1c13f85bd 100644 --- a/backend/src/services/secret-sync/secret-sync-enums.ts +++ b/backend/src/services/secret-sync/secret-sync-enums.ts @@ -18,7 +18,8 @@ export enum SecretSync { OnePass = "1password", Heroku = "heroku", Render = "render", - Flyio = "flyio" + Flyio = "flyio", + CloudflarePages = "cloudflare-pages" } export enum SecretSyncInitialSyncBehavior { diff --git a/backend/src/services/secret-sync/secret-sync-fns.ts b/backend/src/services/secret-sync/secret-sync-fns.ts index 63822a59e..05ebe05ed 100644 --- a/backend/src/services/secret-sync/secret-sync-fns.ts +++ b/backend/src/services/secret-sync/secret-sync-fns.ts @@ -29,6 +29,8 @@ import { AZURE_APP_CONFIGURATION_SYNC_LIST_OPTION, azureAppConfigurationSyncFact import { AZURE_DEVOPS_SYNC_LIST_OPTION, azureDevOpsSyncFactory } from "./azure-devops"; import { AZURE_KEY_VAULT_SYNC_LIST_OPTION, azureKeyVaultSyncFactory } from "./azure-key-vault"; import { CAMUNDA_SYNC_LIST_OPTION, camundaSyncFactory } from "./camunda"; +import { CLOUDFLARE_PAGES_SYNC_LIST_OPTION } from "./cloudflare-pages/cloudflare-pages-constants"; +import { CloudflarePagesSyncFns } from "./cloudflare-pages/cloudflare-pages-fns"; import { FLYIO_SYNC_LIST_OPTION, FlyioSyncFns } from "./flyio"; import { GCP_SYNC_LIST_OPTION } from "./gcp"; import { GcpSyncFns } from "./gcp/gcp-sync-fns"; @@ -63,7 +65,8 @@ const SECRET_SYNC_LIST_OPTIONS: Record = { [SecretSync.OnePass]: ONEPASS_SYNC_LIST_OPTION, [SecretSync.Heroku]: HEROKU_SYNC_LIST_OPTION, [SecretSync.Render]: RENDER_SYNC_LIST_OPTION, - [SecretSync.Flyio]: FLYIO_SYNC_LIST_OPTION + [SecretSync.Flyio]: FLYIO_SYNC_LIST_OPTION, + [SecretSync.CloudflarePages]: CLOUDFLARE_PAGES_SYNC_LIST_OPTION }; export const listSecretSyncOptions = () => { @@ -227,6 +230,8 @@ export const SecretSyncFns = { return RenderSyncFns.syncSecrets(secretSync, schemaSecretMap); case SecretSync.Flyio: return FlyioSyncFns.syncSecrets(secretSync, schemaSecretMap); + case SecretSync.CloudflarePages: + return CloudflarePagesSyncFns.syncSecrets(secretSync, schemaSecretMap); default: throw new Error( `Unhandled sync destination for sync secrets fns: ${(secretSync as TSecretSyncWithCredentials).destination}` @@ -313,6 +318,9 @@ export const SecretSyncFns = { case SecretSync.Flyio: secretMap = await FlyioSyncFns.getSecrets(secretSync); break; + case SecretSync.CloudflarePages: + secretMap = await CloudflarePagesSyncFns.getSecrets(secretSync); + break; default: throw new Error( `Unhandled sync destination for get secrets fns: ${(secretSync as TSecretSyncWithCredentials).destination}` @@ -386,6 +394,8 @@ export const SecretSyncFns = { return RenderSyncFns.removeSecrets(secretSync, schemaSecretMap); case SecretSync.Flyio: return FlyioSyncFns.removeSecrets(secretSync, schemaSecretMap); + case SecretSync.CloudflarePages: + return CloudflarePagesSyncFns.removeSecrets(secretSync, schemaSecretMap); default: throw new Error( `Unhandled sync destination for remove secrets fns: ${(secretSync as TSecretSyncWithCredentials).destination}` diff --git a/backend/src/services/secret-sync/secret-sync-maps.ts b/backend/src/services/secret-sync/secret-sync-maps.ts index f8f5d813c..2e93beed9 100644 --- a/backend/src/services/secret-sync/secret-sync-maps.ts +++ b/backend/src/services/secret-sync/secret-sync-maps.ts @@ -21,7 +21,8 @@ export const SECRET_SYNC_NAME_MAP: Record = { [SecretSync.OnePass]: "1Password", [SecretSync.Heroku]: "Heroku", [SecretSync.Render]: "Render", - [SecretSync.Flyio]: "Fly.io" + [SecretSync.Flyio]: "Fly.io", + [SecretSync.CloudflarePages]: "Cloudflare Pages" }; export const SECRET_SYNC_CONNECTION_MAP: Record = { @@ -44,7 +45,8 @@ export const SECRET_SYNC_CONNECTION_MAP: Record = { [SecretSync.OnePass]: AppConnection.OnePass, [SecretSync.Heroku]: AppConnection.Heroku, [SecretSync.Render]: AppConnection.Render, - [SecretSync.Flyio]: AppConnection.Flyio + [SecretSync.Flyio]: AppConnection.Flyio, + [SecretSync.CloudflarePages]: AppConnection.Cloudflare }; export const SECRET_SYNC_PLAN_MAP: Record = { @@ -67,5 +69,6 @@ export const SECRET_SYNC_PLAN_MAP: Record = { [SecretSync.OnePass]: SecretSyncPlanType.Regular, [SecretSync.Heroku]: SecretSyncPlanType.Regular, [SecretSync.Render]: SecretSyncPlanType.Regular, - [SecretSync.Flyio]: SecretSyncPlanType.Regular + [SecretSync.Flyio]: SecretSyncPlanType.Regular, + [SecretSync.CloudflarePages]: SecretSyncPlanType.Regular }; diff --git a/backend/src/services/secret-sync/secret-sync-types.ts b/backend/src/services/secret-sync/secret-sync-types.ts index d3dddea82..51a64a708 100644 --- a/backend/src/services/secret-sync/secret-sync-types.ts +++ b/backend/src/services/secret-sync/secret-sync-types.ts @@ -106,6 +106,12 @@ import { TTerraformCloudSyncWithCredentials } from "./terraform-cloud"; import { TVercelSync, TVercelSyncInput, TVercelSyncListItem, TVercelSyncWithCredentials } from "./vercel"; +import { + TCloudflarePagesSync, + TCloudflarePagesSyncInput, + TCloudflarePagesSyncListItem, + TCloudflarePagesSyncWithCredentials +} from "./cloudflare-pages/cloudflare-pages-types"; export type TSecretSync = | TAwsParameterStoreSync @@ -127,7 +133,8 @@ export type TSecretSync = | TOnePassSync | THerokuSync | TRenderSync - | TFlyioSync; + | TFlyioSync + | TCloudflarePagesSync; export type TSecretSyncWithCredentials = | TAwsParameterStoreSyncWithCredentials @@ -149,7 +156,8 @@ export type TSecretSyncWithCredentials = | TOnePassSyncWithCredentials | THerokuSyncWithCredentials | TRenderSyncWithCredentials - | TFlyioSyncWithCredentials; + | TFlyioSyncWithCredentials + | TCloudflarePagesSyncWithCredentials; export type TSecretSyncInput = | TAwsParameterStoreSyncInput @@ -171,7 +179,8 @@ export type TSecretSyncInput = | TOnePassSyncInput | THerokuSyncInput | TRenderSyncInput - | TFlyioSyncInput; + | TFlyioSyncInput + | TCloudflarePagesSyncInput; export type TSecretSyncListItem = | TAwsParameterStoreSyncListItem @@ -193,7 +202,8 @@ export type TSecretSyncListItem = | TOnePassSyncListItem | THerokuSyncListItem | TRenderSyncListItem - | TFlyioSyncListItem; + | TFlyioSyncListItem + | TCloudflarePagesSyncListItem; export type TSyncOptionsConfig = { canImportSecrets: boolean; diff --git a/docs/api-reference/endpoints/app-connections/cloudflare/available.mdx b/docs/api-reference/endpoints/app-connections/cloudflare/available.mdx new file mode 100644 index 000000000..bc61552bf --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/cloudflare/available.mdx @@ -0,0 +1,4 @@ +--- +title: "Available" +openapi: "GET /api/v1/app-connections/cloudflare/available" +--- diff --git a/docs/api-reference/endpoints/app-connections/cloudflare/create.mdx b/docs/api-reference/endpoints/app-connections/cloudflare/create.mdx new file mode 100644 index 000000000..a364b2268 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/cloudflare/create.mdx @@ -0,0 +1,10 @@ +--- +title: "Create" +openapi: "POST /api/v1/app-connections/cloudflare" +--- + + + Check out the configuration docs for [Cloudflare + Connections](/integrations/app-connections/cloudflare) to learn how to obtain + the required credentials. + diff --git a/docs/api-reference/endpoints/app-connections/cloudflare/delete.mdx b/docs/api-reference/endpoints/app-connections/cloudflare/delete.mdx new file mode 100644 index 000000000..d0d766717 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/cloudflare/delete.mdx @@ -0,0 +1,4 @@ +--- +title: "Delete" +openapi: "DELETE /api/v1/app-connections/cloudflare/{connectionId}" +--- diff --git a/docs/api-reference/endpoints/app-connections/cloudflare/get-by-id.mdx b/docs/api-reference/endpoints/app-connections/cloudflare/get-by-id.mdx new file mode 100644 index 000000000..b1590da75 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/cloudflare/get-by-id.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by ID" +openapi: "GET /api/v1/app-connections/cloudflare/{connectionId}" +--- diff --git a/docs/api-reference/endpoints/app-connections/cloudflare/get-by-name.mdx b/docs/api-reference/endpoints/app-connections/cloudflare/get-by-name.mdx new file mode 100644 index 000000000..b3613d079 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/cloudflare/get-by-name.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by Name" +openapi: "GET /api/v1/app-connections/cloudflare/connection-name/{connectionName}" +--- diff --git a/docs/api-reference/endpoints/app-connections/cloudflare/list.mdx b/docs/api-reference/endpoints/app-connections/cloudflare/list.mdx new file mode 100644 index 000000000..df0189c45 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/cloudflare/list.mdx @@ -0,0 +1,4 @@ +--- +title: "List" +openapi: "GET /api/v1/app-connections/cloudflare" +--- diff --git a/docs/api-reference/endpoints/app-connections/cloudflare/update.mdx b/docs/api-reference/endpoints/app-connections/cloudflare/update.mdx new file mode 100644 index 000000000..dbd70acde --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/cloudflare/update.mdx @@ -0,0 +1,10 @@ +--- +title: "Update" +openapi: "PATCH /api/v1/app-connections/cloudflare/{connectionId}" +--- + + + Check out the configuration docs for [Cloudflare + Connections](/integrations/app-connections/cloudflare) to learn how to obtain + the required credentials. + diff --git a/docs/api-reference/endpoints/secret-syncs/cloudflare-pages/create.mdx b/docs/api-reference/endpoints/secret-syncs/cloudflare-pages/create.mdx new file mode 100644 index 000000000..c30499bfc --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/cloudflare-pages/create.mdx @@ -0,0 +1,4 @@ +--- +title: "Create" +openapi: "POST /api/v1/secret-syncs/cloudflare-pages" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/cloudflare-pages/delete.mdx b/docs/api-reference/endpoints/secret-syncs/cloudflare-pages/delete.mdx new file mode 100644 index 000000000..2ff903032 --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/cloudflare-pages/delete.mdx @@ -0,0 +1,4 @@ +--- +title: "Delete" +openapi: "DELETE /api/v1/secret-syncs/cloudflare-pages/{syncId}" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/cloudflare-pages/get-by-id.mdx b/docs/api-reference/endpoints/secret-syncs/cloudflare-pages/get-by-id.mdx new file mode 100644 index 000000000..b6c81cb5e --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/cloudflare-pages/get-by-id.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by ID" +openapi: "GET /api/v1/secret-syncs/cloudflare-pages/{syncId}" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/cloudflare-pages/get-by-name.mdx b/docs/api-reference/endpoints/secret-syncs/cloudflare-pages/get-by-name.mdx new file mode 100644 index 000000000..56d69b5b4 --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/cloudflare-pages/get-by-name.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by Name" +openapi: "GET /api/v1/secret-syncs/cloudflare-pages/sync-name/{syncName}" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/cloudflare-pages/list.mdx b/docs/api-reference/endpoints/secret-syncs/cloudflare-pages/list.mdx new file mode 100644 index 000000000..631f06ec6 --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/cloudflare-pages/list.mdx @@ -0,0 +1,4 @@ +--- +title: "List" +openapi: "GET /api/v1/secret-syncs/cloudflare-pages" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/cloudflare-pages/remove-secrets.mdx b/docs/api-reference/endpoints/secret-syncs/cloudflare-pages/remove-secrets.mdx new file mode 100644 index 000000000..b113a86dc --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/cloudflare-pages/remove-secrets.mdx @@ -0,0 +1,4 @@ +--- +title: "Remove Secrets" +openapi: "POST /api/v1/secret-syncs/cloudflare-pages/{syncId}/remove-secrets" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/cloudflare-pages/sync-secrets.mdx b/docs/api-reference/endpoints/secret-syncs/cloudflare-pages/sync-secrets.mdx new file mode 100644 index 000000000..53d5e4b85 --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/cloudflare-pages/sync-secrets.mdx @@ -0,0 +1,4 @@ +--- +title: "Sync Secrets" +openapi: "POST /api/v1/secret-syncs/cloudflare-pages/{syncId}/sync-secrets" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/cloudflare-pages/update.mdx b/docs/api-reference/endpoints/secret-syncs/cloudflare-pages/update.mdx new file mode 100644 index 000000000..4f11328d0 --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/cloudflare-pages/update.mdx @@ -0,0 +1,4 @@ +--- +title: "Update" +openapi: "PATCH /api/v1/secret-syncs/cloudflare-pages/{syncId}" +--- diff --git a/docs/docs.json b/docs/docs.json index 2d0417778..f22c95685 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -469,6 +469,7 @@ "integrations/app-connections/azure-devops", "integrations/app-connections/azure-key-vault", "integrations/app-connections/camunda", + "integrations/app-connections/cloudflare", "integrations/app-connections/databricks", "integrations/app-connections/flyio", "integrations/app-connections/gcp", @@ -506,6 +507,7 @@ "integrations/secret-syncs/azure-devops", "integrations/secret-syncs/azure-key-vault", "integrations/secret-syncs/camunda", + "integrations/secret-syncs/cloudflare-pages", "integrations/secret-syncs/databricks", "integrations/secret-syncs/flyio", "integrations/secret-syncs/gcp-secret-manager", @@ -1255,6 +1257,18 @@ "api-reference/endpoints/app-connections/camunda/delete" ] }, + { + "group": "Cloudflare", + "pages": [ + "api-reference/endpoints/app-connections/cloudflare/list", + "api-reference/endpoints/app-connections/cloudflare/available", + "api-reference/endpoints/app-connections/cloudflare/get-by-id", + "api-reference/endpoints/app-connections/cloudflare/get-by-name", + "api-reference/endpoints/app-connections/cloudflare/create", + "api-reference/endpoints/app-connections/cloudflare/update", + "api-reference/endpoints/app-connections/cloudflare/delete" + ] + }, { "group": "Databricks", "pages": [ @@ -1587,6 +1601,19 @@ "api-reference/endpoints/secret-syncs/camunda/remove-secrets" ] }, + { + "group": "Cloudflare Pages", + "pages": [ + "api-reference/endpoints/secret-syncs/cloudflare-pages/list", + "api-reference/endpoints/secret-syncs/cloudflare-pages/get-by-id", + "api-reference/endpoints/secret-syncs/cloudflare-pages/get-by-name", + "api-reference/endpoints/secret-syncs/cloudflare-pages/create", + "api-reference/endpoints/secret-syncs/cloudflare-pages/update", + "api-reference/endpoints/secret-syncs/cloudflare-pages/delete", + "api-reference/endpoints/secret-syncs/cloudflare-pages/sync-secrets", + "api-reference/endpoints/secret-syncs/cloudflare-pages/remove-secrets" + ] + }, { "group": "Databricks", "pages": [ diff --git a/docs/images/app-connections/cloudflare/cloudflare-account-id.png b/docs/images/app-connections/cloudflare/cloudflare-account-id.png new file mode 100644 index 000000000..865b2ff76 Binary files /dev/null and b/docs/images/app-connections/cloudflare/cloudflare-account-id.png differ diff --git a/docs/images/app-connections/cloudflare/cloudflare-app-connection-created.png b/docs/images/app-connections/cloudflare/cloudflare-app-connection-created.png new file mode 100644 index 000000000..168d36da5 Binary files /dev/null and b/docs/images/app-connections/cloudflare/cloudflare-app-connection-created.png differ diff --git a/docs/images/app-connections/cloudflare/cloudflare-app-connection-form.png b/docs/images/app-connections/cloudflare/cloudflare-app-connection-form.png new file mode 100644 index 000000000..78af87a6f Binary files /dev/null and b/docs/images/app-connections/cloudflare/cloudflare-app-connection-form.png differ diff --git a/docs/images/app-connections/cloudflare/cloudflare-app-connection-select.png b/docs/images/app-connections/cloudflare/cloudflare-app-connection-select.png new file mode 100644 index 000000000..85d2c401a Binary files /dev/null and b/docs/images/app-connections/cloudflare/cloudflare-app-connection-select.png differ diff --git a/docs/images/app-connections/cloudflare/cloudflare-create-token.png b/docs/images/app-connections/cloudflare/cloudflare-create-token.png new file mode 100644 index 000000000..7fba350fe Binary files /dev/null and b/docs/images/app-connections/cloudflare/cloudflare-create-token.png differ diff --git a/docs/images/app-connections/cloudflare/cloudflare-generated-token.png b/docs/images/app-connections/cloudflare/cloudflare-generated-token.png new file mode 100644 index 000000000..e470bcbb7 Binary files /dev/null and b/docs/images/app-connections/cloudflare/cloudflare-generated-token.png differ diff --git a/docs/images/app-connections/cloudflare/cloudflare-navigate-profile.png b/docs/images/app-connections/cloudflare/cloudflare-navigate-profile.png new file mode 100644 index 000000000..4285b4c91 Binary files /dev/null and b/docs/images/app-connections/cloudflare/cloudflare-navigate-profile.png differ diff --git a/docs/images/app-connections/cloudflare/cloudflare-pages-configure-permissions.png b/docs/images/app-connections/cloudflare/cloudflare-pages-configure-permissions.png new file mode 100644 index 000000000..d678fca68 Binary files /dev/null and b/docs/images/app-connections/cloudflare/cloudflare-pages-configure-permissions.png differ diff --git a/docs/images/secret-syncs/cloudflare-pages/cloudflare-pages-sync-created.png b/docs/images/secret-syncs/cloudflare-pages/cloudflare-pages-sync-created.png new file mode 100644 index 000000000..f01787acb Binary files /dev/null and b/docs/images/secret-syncs/cloudflare-pages/cloudflare-pages-sync-created.png differ diff --git a/docs/images/secret-syncs/cloudflare-pages/cloudflare-pages-sync-destination.png b/docs/images/secret-syncs/cloudflare-pages/cloudflare-pages-sync-destination.png new file mode 100644 index 000000000..74d57d66c Binary files /dev/null and b/docs/images/secret-syncs/cloudflare-pages/cloudflare-pages-sync-destination.png differ diff --git a/docs/images/secret-syncs/cloudflare-pages/cloudflare-pages-sync-details.png b/docs/images/secret-syncs/cloudflare-pages/cloudflare-pages-sync-details.png new file mode 100644 index 000000000..f347600b0 Binary files /dev/null and b/docs/images/secret-syncs/cloudflare-pages/cloudflare-pages-sync-details.png differ diff --git a/docs/images/secret-syncs/cloudflare-pages/cloudflare-pages-sync-options.png b/docs/images/secret-syncs/cloudflare-pages/cloudflare-pages-sync-options.png new file mode 100644 index 000000000..f6e67aabb Binary files /dev/null and b/docs/images/secret-syncs/cloudflare-pages/cloudflare-pages-sync-options.png differ diff --git a/docs/images/secret-syncs/cloudflare-pages/cloudflare-pages-sync-review.png b/docs/images/secret-syncs/cloudflare-pages/cloudflare-pages-sync-review.png new file mode 100644 index 000000000..a4b43e3cb Binary files /dev/null and b/docs/images/secret-syncs/cloudflare-pages/cloudflare-pages-sync-review.png differ diff --git a/docs/images/secret-syncs/cloudflare-pages/cloudflare-pages-sync-source.png b/docs/images/secret-syncs/cloudflare-pages/cloudflare-pages-sync-source.png new file mode 100644 index 000000000..e57d2d5be Binary files /dev/null and b/docs/images/secret-syncs/cloudflare-pages/cloudflare-pages-sync-source.png differ diff --git a/docs/images/secret-syncs/cloudflare-pages/select-cloudflare-pages-option.png b/docs/images/secret-syncs/cloudflare-pages/select-cloudflare-pages-option.png new file mode 100644 index 000000000..5246225a5 Binary files /dev/null and b/docs/images/secret-syncs/cloudflare-pages/select-cloudflare-pages-option.png differ diff --git a/docs/integrations/app-connections/cloudflare.mdx b/docs/integrations/app-connections/cloudflare.mdx new file mode 100644 index 000000000..66ba1256f --- /dev/null +++ b/docs/integrations/app-connections/cloudflare.mdx @@ -0,0 +1,94 @@ +--- +title: "Cloudflare Connection" +description: "Learn how to configure a Cloudflare Connection for Infisical." +--- + +Infisical supports connecting to Cloudflare using API tokens and Account ID for secure access to your Cloudflare services. + +## Configure API Token and Account ID for Infisical + + + + Navigate to your Cloudflare dashboard and go to **Profile**. + + ![Navigate Cloudflare Profile](/images/app-connections/cloudflare/cloudflare-navigate-profile.png) + + Click **API Tokens > Create Token** to generate a new API token. + + ![Create API Token](/images/app-connections/cloudflare/cloudflare-create-token.png) + + + + Configure your API token with the necessary permissions for your Cloudflare services. + + Depending on your use case, add one or more of the following permission sets to your API token: + + + + + + Use the following permissions to grant Infisical access to sync secrets to Cloudflare Pages: + + ![Configure Token](/images/app-connections/cloudflare/cloudflare-pages-configure-permissions.png) + + **Required Permissions:** + - **Account** - **Cloudflare Pages** - **Edit** + - **Account** - **Account Settings** - **Read** + + Add these permissions to your API token and click **Continue to summary**, then **Create Token** to generate your API token. + + + + + + + + After creation, copy and securely store your API token as it will not be shown again. + + ![Generated API Token](/images/app-connections/cloudflare/cloudflare-generated-token.png) + + + Keep your API token secure and do not share it. Anyone with access to this token can manage your Cloudflare resources based on the permissions granted. + + + + + From your Cloudflare Account Home page, click on the account information dropdown and select **Copy account ID**. + + ![Account ID](/images/app-connections/cloudflare/cloudflare-account-id.png) + + Save your Account ID for use in the next step. + + + + +## Setup Cloudflare Connection in Infisical + + + + Navigate to the **App Connections** tab on the **Organization Settings** + page. ![App Connections + Tab](/images/app-connections/general/add-connection.png) + + + Select the **Cloudflare Connection** option from the connection options + modal. ![Select Cloudflare + Connection](/images/app-connections/cloudflare/cloudflare-app-connection-select.png) + + + Enter your Cloudflare API token and Account ID in the provided fields and + click **Connect to Cloudflare** to establish the connection. ![Connect to + Cloudflare](/images/app-connections/cloudflare/cloudflare-app-connection-form.png) + + + Your **Cloudflare Connection** is now available for use in your Infisical + projects. ![Cloudflare Connection + Created](/images/app-connections/cloudflare/cloudflare-app-connection-created.png) + + + + + API token connections require manual token rotation when your Cloudflare API + token expires or is regenerated. Monitor your connection status and update the + token as needed. + diff --git a/docs/integrations/secret-syncs/cloudflare-pages.mdx b/docs/integrations/secret-syncs/cloudflare-pages.mdx new file mode 100644 index 000000000..1375de7a7 --- /dev/null +++ b/docs/integrations/secret-syncs/cloudflare-pages.mdx @@ -0,0 +1,133 @@ +--- +title: "Cloudflare Pages Sync" +description: "Learn how to configure a Cloudflare Pages Sync for Infisical." +--- + +**Prerequisites:** + +- Set up and add secrets to [Infisical Cloud](https://app.infisical.com) +- Create a [Cloudflare Connection](/integrations/app-connections/cloudflare) + + + + 1. Navigate to **Project** > **Integrations** and select the **Secret Syncs** tab. Click on the **Add Sync** button. + ![Secret Syncs Tab](/images/secret-syncs/general/secret-sync-tab.png) + + 2. Select the **Cloudflare Pages** option. + ![Select Cloudflare Pages](/images/secret-syncs/cloudflare-pages/select-cloudflare-pages-option.png) + + 3. Configure the **Source** from where secrets should be retrieved, then click **Next**. + ![Configure Source](/images/secret-syncs/cloudflare-pages/cloudflare-pages-sync-source.png) + + - **Environment**: The project environment to retrieve secrets from. + - **Secret Path**: The folder path to retrieve secrets from. + + + If you need to sync secrets from multiple folder locations, check out [secret imports](/documentation/platform/secret-reference#secret-imports). + + + 4. Configure the **Destination** to where secrets should be deployed, then click **Next**. + ![Configure Destination](/images/secret-syncs/cloudflare-pages/cloudflare-pages-sync-destination.png) + + - **Cloudflare Connection**: The Cloudflare Connection to authenticate with. + - **Cloudflare Pages Project**: Choose the Cloudflare Pages project you want to sync secrets to. + - **Environment**: Select the deployment environment (preview or production). + + 5. Configure the **Sync Options** to specify how secrets should be synced, then click **Next**. + ![Configure Options](/images/secret-syncs/cloudflare-pages/cloudflare-pages-sync-options.png) + + - **Initial Sync Behavior**: Determines how Infisical should resolve the initial sync. + - **Overwrite Destination Secrets**: Removes any secrets at the destination endpoint not present in Infisical. + - **Key Schema**: Template that determines how secret names are transformed when syncing, using `{{secretKey}}` as a placeholder for the original secret name and `{{environment}}` for the environment. + - **Auto-Sync Enabled**: If enabled, secrets will automatically be synced from the source location when changes occur. Disable to enforce manual syncing only. + - **Disable Secret Deletion**: If enabled, Infisical will not remove secrets from the sync destination. Enable this option if you intend to manage some secrets manually outside of Infisical. + + 6. Configure the **Details** of your Cloudflare Pages Sync, then click **Next**. + ![Configure Details](/images/secret-syncs/cloudflare-pages/cloudflare-pages-sync-details.png) + + - **Name**: The name of your sync. Must be slug-friendly. + - **Description**: An optional description for your sync. + + 7. Review your Cloudflare Pages Sync configuration, then click **Create Sync**. + ![Confirm Configuration](/images/secret-syncs/cloudflare-pages/cloudflare-pages-sync-review.png) + + 8. If enabled, your Cloudflare Pages Sync will begin syncing your secrets to the destination endpoint. + ![Sync Secrets](/images/secret-syncs/cloudflare-pages/cloudflare-pages-sync-created.png) + + + + To create a **Cloudflare Pages Sync**, make an API request to the [Create Cloudflare Pages Sync](/api-reference/endpoints/secret-syncs/cloudflare-pages/create) API endpoint. + + ### Sample request + + ```bash Request + curl --request POST \ + --url https://app.infisical.com/api/v1/secret-syncs/cloudflare-pages \ + --header 'Content-Type: application/json' \ + --data '{ + "name": "my-cloudflare-pages-sync", + "projectId": "your-project-id", + "description": "an example sync", + "connectionId": "your-cloudflare-connection-id", + "environment": "production", + "secretPath": "/my-secrets", + "isEnabled": true, + "syncOptions": { + "initialSyncBehavior": "overwrite-destination" + }, + "destinationConfig": { + "projectId": "your-cloudflare-pages-project-id", + "projectName": "my-pages-project", + "environment": "production" + } + }' + ``` + + ### Sample response + + ```bash Response + { + "secretSync": { + "id": "your-sync-id", + "name": "my-cloudflare-pages-sync", + "description": "an example sync", + "isEnabled": true, + "version": 1, + "folderId": "your-folder-id", + "connectionId": "your-cloudflare-connection-id", + "createdAt": "2024-05-01T12:00:00Z", + "updatedAt": "2024-05-01T12:00:00Z", + "syncStatus": "succeeded", + "lastSyncJobId": "123", + "lastSyncMessage": null, + "lastSyncedAt": "2024-05-01T12:00:00Z", + "syncOptions": { + "initialSyncBehavior": "overwrite-destination" + }, + "projectId": "your-project-id", + "connection": { + "app": "cloudflare", + "name": "my-cloudflare-connection", + "id": "your-cloudflare-connection-id" + }, + "environment": { + "slug": "production", + "name": "Production", + "id": "your-env-id" + }, + "folder": { + "id": "your-folder-id", + "path": "/my-secrets" + }, + "destination": "cloudflare-pages", + "destinationConfig": { + "projectId": "your-cloudflare-pages-project-id", + "projectName": "my-pages-project", + "environment": "production" + } + } + } + ``` + + + diff --git a/frontend/src/components/secret-syncs/CreateSecretSyncModal.tsx b/frontend/src/components/secret-syncs/CreateSecretSyncModal.tsx index a7c211bff..68866dcbd 100644 --- a/frontend/src/components/secret-syncs/CreateSecretSyncModal.tsx +++ b/frontend/src/components/secret-syncs/CreateSecretSyncModal.tsx @@ -59,7 +59,6 @@ export const CreateSecretSyncModal = ({ onOpenChange, selectSync = null, ...prop onPointerDownOutside={(e) => e.preventDefault()} className="max-w-2xl" subTitle={selectedSync ? undefined : "Select a third-party service to sync secrets to."} - bodyClassName="overflow-visible" > { diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/CloudflarePagesSyncFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/CloudflarePagesSyncFields.tsx new file mode 100644 index 000000000..d19092d63 --- /dev/null +++ b/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/CloudflarePagesSyncFields.tsx @@ -0,0 +1,95 @@ +import { Controller, useFormContext, useWatch } from "react-hook-form"; +import { SingleValue } from "react-select"; + +import { SecretSyncConnectionField } from "@app/components/secret-syncs/forms/SecretSyncConnectionField"; +import { FilterableSelect, FormControl, Select, SelectItem } from "@app/components/v2"; +import { + TCloudflareProject, + useCloudflareConnectionListPagesProjects +} from "@app/hooks/api/appConnections/cloudflare"; +import { SecretSync } from "@app/hooks/api/secretSyncs"; + +import { TSecretSyncForm } from "../schemas"; + +const CLOUDFLARE_ENVIRONMENTS = [ + { + name: "Preview", + value: "preview" + }, + { + name: "Production", + value: "production" + } +]; + +export const CloudflarePagesSyncFields = () => { + const { control, setValue } = useFormContext< + TSecretSyncForm & { destination: SecretSync.CloudflarePages } + >(); + + const connectionId = useWatch({ name: "connection.id", control }); + + const { data: projects = [], isPending: isProjectsPending } = + useCloudflareConnectionListPagesProjects(connectionId, { + enabled: Boolean(connectionId) + }); + + return ( + <> + { + setValue("destinationConfig.projectName", ""); + setValue("destinationConfig.environment", "preview"); + }} + /> + ( + + project.name === value) ?? []) : []} + onChange={(option) => { + onChange((option as SingleValue)?.name ?? null); + }} + options={projects} + placeholder="Select a project..." + getOptionLabel={(option) => option.name} + getOptionValue={(option) => option.id.toString()} + /> + + )} + /> + ( + + + + )} + /> + + ); +}; diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/SecretSyncDestinationFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/SecretSyncDestinationFields.tsx index 64345aae4..74824a28e 100644 --- a/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/SecretSyncDestinationFields.tsx +++ b/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/SecretSyncDestinationFields.tsx @@ -10,6 +10,7 @@ import { AzureAppConfigurationSyncFields } from "./AzureAppConfigurationSyncFiel import { AzureDevOpsSyncFields } from "./AzureDevOpsSyncFields"; import { AzureKeyVaultSyncFields } from "./AzureKeyVaultSyncFields"; import { CamundaSyncFields } from "./CamundaSyncFields"; +import { CloudflarePagesSyncFields } from "./CloudflarePagesSyncFields"; import { DatabricksSyncFields } from "./DatabricksSyncFields"; import { FlyioSyncFields } from "./FlyioSyncFields"; import { GcpSyncFields } from "./GcpSyncFields"; @@ -70,6 +71,8 @@ export const SecretSyncDestinationFields = () => { return ; case SecretSync.Flyio: return ; + case SecretSync.CloudflarePages: + return ; default: throw new Error(`Unhandled Destination Config Field: ${destination}`); } diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncOptionsFields/SecretSyncOptionsFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncOptionsFields/SecretSyncOptionsFields.tsx index 55aeca3c6..b44c3ae51 100644 --- a/frontend/src/components/secret-syncs/forms/SecretSyncOptionsFields/SecretSyncOptionsFields.tsx +++ b/frontend/src/components/secret-syncs/forms/SecretSyncOptionsFields/SecretSyncOptionsFields.tsx @@ -55,6 +55,7 @@ export const SecretSyncOptionsFields = ({ hideInitialSync }: Props) => { case SecretSync.Heroku: case SecretSync.Render: case SecretSync.Flyio: + case SecretSync.CloudflarePages: AdditionalSyncOptionsFieldsComponent = null; break; default: diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/CloudflarePagesReviewFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/CloudflarePagesReviewFields.tsx new file mode 100644 index 000000000..ab18be5da --- /dev/null +++ b/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/CloudflarePagesReviewFields.tsx @@ -0,0 +1,18 @@ +import { useFormContext } from "react-hook-form"; + +import { TSecretSyncForm } from "@app/components/secret-syncs/forms/schemas"; +import { GenericFieldLabel } from "@app/components/v2"; +import { SecretSync } from "@app/hooks/api/secretSyncs"; + +export const CloudflarePagesSyncReviewFields = () => { + const { watch } = useFormContext(); + const projectName = watch("destinationConfig.projectName"); + const environment = watch("destinationConfig.environment"); + + return ( + <> + {projectName} + {environment} + + ); +}; diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/SecretSyncReviewFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/SecretSyncReviewFields.tsx index 2131518a0..26ab0bfcf 100644 --- a/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/SecretSyncReviewFields.tsx +++ b/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/SecretSyncReviewFields.tsx @@ -19,6 +19,7 @@ import { AzureAppConfigurationSyncReviewFields } from "./AzureAppConfigurationSy import { AzureDevOpsSyncReviewFields } from "./AzureDevOpsSyncReviewFields"; import { AzureKeyVaultSyncReviewFields } from "./AzureKeyVaultSyncReviewFields"; import { CamundaSyncReviewFields } from "./CamundaSyncReviewFields"; +import { CloudflarePagesSyncReviewFields } from "./CloudflarePagesReviewFields"; import { DatabricksSyncReviewFields } from "./DatabricksSyncReviewFields"; import { FlyioSyncReviewFields } from "./FlyioSyncReviewFields"; import { GcpSyncReviewFields } from "./GcpSyncReviewFields"; @@ -116,6 +117,9 @@ export const SecretSyncReviewFields = () => { case SecretSync.Flyio: DestinationFieldsComponent = ; break; + case SecretSync.CloudflarePages: + DestinationFieldsComponent = ; + break; default: throw new Error(`Unhandled Destination Review Fields: ${destination}`); } diff --git a/frontend/src/components/secret-syncs/forms/schemas/cloudflare-pages-sync-destination-schema.ts b/frontend/src/components/secret-syncs/forms/schemas/cloudflare-pages-sync-destination-schema.ts new file mode 100644 index 000000000..c44a579db --- /dev/null +++ b/frontend/src/components/secret-syncs/forms/schemas/cloudflare-pages-sync-destination-schema.ts @@ -0,0 +1,14 @@ +import { z } from "zod"; + +import { BaseSecretSyncSchema } from "@app/components/secret-syncs/forms/schemas/base-secret-sync-schema"; +import { SecretSync } from "@app/hooks/api/secretSyncs"; + +export const CloudflarePagesSyncDestinationSchema = BaseSecretSyncSchema().merge( + z.object({ + destination: z.literal(SecretSync.CloudflarePages), + destinationConfig: z.object({ + projectName: z.string().trim().min(1, "Project name is required"), + environment: z.string().trim().min(1, "Environment is required") + }) + }) +); diff --git a/frontend/src/components/secret-syncs/forms/schemas/secret-sync-schema.ts b/frontend/src/components/secret-syncs/forms/schemas/secret-sync-schema.ts index b296cb6d6..eb01e28c1 100644 --- a/frontend/src/components/secret-syncs/forms/schemas/secret-sync-schema.ts +++ b/frontend/src/components/secret-syncs/forms/schemas/secret-sync-schema.ts @@ -7,6 +7,7 @@ import { AzureAppConfigurationSyncDestinationSchema } from "./azure-app-configur import { AzureDevOpsSyncDestinationSchema } from "./azure-devops-sync-destination-schema"; import { AzureKeyVaultSyncDestinationSchema } from "./azure-key-vault-sync-destination-schema"; import { CamundaSyncDestinationSchema } from "./camunda-sync-destination-schema"; +import { CloudflarePagesSyncDestinationSchema } from "./cloudflare-pages-sync-destination-schema"; import { DatabricksSyncDestinationSchema } from "./databricks-sync-destination-schema"; import { FlyioSyncDestinationSchema } from "./flyio-sync-destination-schema"; import { GcpSyncDestinationSchema } from "./gcp-sync-destination-schema"; @@ -41,7 +42,8 @@ const SecretSyncUnionSchema = z.discriminatedUnion("destination", [ OnePassSyncDestinationSchema, HerokuSyncDestinationSchema, RenderSyncDestinationSchema, - FlyioSyncDestinationSchema + FlyioSyncDestinationSchema, + CloudflarePagesSyncDestinationSchema ]); export const SecretSyncFormSchema = SecretSyncUnionSchema; diff --git a/frontend/src/helpers/appConnections.ts b/frontend/src/helpers/appConnections.ts index d0e5be036..b723a8417 100644 --- a/frontend/src/helpers/appConnections.ts +++ b/frontend/src/helpers/appConnections.ts @@ -18,6 +18,7 @@ import { AzureDevOpsConnectionMethod, AzureKeyVaultConnectionMethod, CamundaConnectionMethod, + CloudflareConnectionMethod, DatabricksConnectionMethod, FlyioConnectionMethod, GcpConnectionMethod, @@ -84,7 +85,8 @@ export const APP_CONNECTION_MAP: Record< [AppConnection.OnePass]: { name: "1Password", image: "1Password.png" }, [AppConnection.Heroku]: { name: "Heroku", image: "Heroku.png" }, [AppConnection.Render]: { name: "Render", image: "Render.png" }, - [AppConnection.Flyio]: { name: "Fly.io", image: "Flyio.svg" } + [AppConnection.Flyio]: { name: "Fly.io", image: "Flyio.svg" }, + [AppConnection.Cloudflare]: { name: "Cloudflare", image: "Cloudflare.png" } }; export const getAppConnectionMethodDetails = (method: TAppConnection["method"]) => { @@ -114,6 +116,7 @@ export const getAppConnectionMethodDetails = (method: TAppConnection["method"]) case TerraformCloudConnectionMethod.ApiToken: case VercelConnectionMethod.ApiToken: case OnePassConnectionMethod.ApiToken: + case CloudflareConnectionMethod.ApiToken: return { name: "API Token", icon: faKey }; case PostgresConnectionMethod.UsernameAndPassword: case MsSqlConnectionMethod.UsernameAndPassword: diff --git a/frontend/src/helpers/secretSyncs.ts b/frontend/src/helpers/secretSyncs.ts index afcd32958..7d75f5859 100644 --- a/frontend/src/helpers/secretSyncs.ts +++ b/frontend/src/helpers/secretSyncs.ts @@ -73,6 +73,10 @@ export const SECRET_SYNC_MAP: Record = { [SecretSync.OnePass]: AppConnection.OnePass, [SecretSync.Heroku]: AppConnection.Heroku, [SecretSync.Render]: AppConnection.Render, - [SecretSync.Flyio]: AppConnection.Flyio + [SecretSync.Flyio]: AppConnection.Flyio, + [SecretSync.CloudflarePages]: AppConnection.Cloudflare }; export const SECRET_SYNC_INITIAL_SYNC_BEHAVIOR_MAP: Record< diff --git a/frontend/src/hooks/api/appConnections/cloudflare/index.ts b/frontend/src/hooks/api/appConnections/cloudflare/index.ts new file mode 100644 index 000000000..2c1906d36 --- /dev/null +++ b/frontend/src/hooks/api/appConnections/cloudflare/index.ts @@ -0,0 +1,2 @@ +export * from "./queries"; +export * from "./types"; diff --git a/frontend/src/hooks/api/appConnections/cloudflare/queries.tsx b/frontend/src/hooks/api/appConnections/cloudflare/queries.tsx new file mode 100644 index 000000000..ae2efea99 --- /dev/null +++ b/frontend/src/hooks/api/appConnections/cloudflare/queries.tsx @@ -0,0 +1,37 @@ +import { useQuery, UseQueryOptions } from "@tanstack/react-query"; + +import { apiRequest } from "@app/config/request"; + +import { appConnectionKeys } from "../queries"; +import { TCloudflareProject } from "./types"; + +const cloudflareConnectionKeys = { + all: [...appConnectionKeys.all, "cloudflare"] as const, + listPagesProjects: (connectionId: string) => + [...cloudflareConnectionKeys.all, "pages-projects", connectionId] as const +}; + +export const useCloudflareConnectionListPagesProjects = ( + connectionId: string, + options?: Omit< + UseQueryOptions< + TCloudflareProject[], + unknown, + TCloudflareProject[], + ReturnType + >, + "queryKey" | "queryFn" + > +) => { + return useQuery({ + queryKey: cloudflareConnectionKeys.listPagesProjects(connectionId), + queryFn: async () => { + const { data } = await apiRequest.get( + `/api/v1/app-connections/cloudflare/${connectionId}/cloudflare-pages-projects` + ); + + return data; + }, + ...options + }); +}; diff --git a/frontend/src/hooks/api/appConnections/cloudflare/types.ts b/frontend/src/hooks/api/appConnections/cloudflare/types.ts new file mode 100644 index 000000000..ed7bda478 --- /dev/null +++ b/frontend/src/hooks/api/appConnections/cloudflare/types.ts @@ -0,0 +1,4 @@ +export type TCloudflareProject = { + id: string; + name: string; +}; diff --git a/frontend/src/hooks/api/appConnections/enums.ts b/frontend/src/hooks/api/appConnections/enums.ts index d4ffdaba3..f64580c16 100644 --- a/frontend/src/hooks/api/appConnections/enums.ts +++ b/frontend/src/hooks/api/appConnections/enums.ts @@ -25,5 +25,6 @@ export enum AppConnection { OnePass = "1password", Heroku = "heroku", Render = "render", - Flyio = "flyio" + Flyio = "flyio", + Cloudflare = "cloudflare" } diff --git a/frontend/src/hooks/api/appConnections/types/app-options.ts b/frontend/src/hooks/api/appConnections/types/app-options.ts index 1332354ec..3550a0d9b 100644 --- a/frontend/src/hooks/api/appConnections/types/app-options.ts +++ b/frontend/src/hooks/api/appConnections/types/app-options.ts @@ -123,6 +123,10 @@ export type TFlyioConnectionOption = TAppConnectionOptionBase & { app: AppConnection.Flyio; }; +export type TCloudflareConnectionOption = TAppConnectionOptionBase & { + app: AppConnection.Cloudflare; +}; + export type TAppConnectionOption = | TAwsConnectionOption | TGitHubConnectionOption @@ -148,7 +152,8 @@ export type TAppConnectionOption = | TOnePassConnectionOption | THerokuConnectionOption | TRenderConnectionOption - | TFlyioConnectionOption; + | TFlyioConnectionOption + | TCloudflareConnectionOption; export type TAppConnectionOptionMap = { [AppConnection.AWS]: TAwsConnectionOption; @@ -178,4 +183,5 @@ export type TAppConnectionOptionMap = { [AppConnection.Heroku]: THerokuConnectionOption; [AppConnection.Render]: TRenderConnectionOption; [AppConnection.Flyio]: TFlyioConnectionOption; + [AppConnection.Cloudflare]: TCloudflareConnectionOption; }; diff --git a/frontend/src/hooks/api/appConnections/types/cloudflare-connection.ts b/frontend/src/hooks/api/appConnections/types/cloudflare-connection.ts new file mode 100644 index 000000000..6ef7dda95 --- /dev/null +++ b/frontend/src/hooks/api/appConnections/types/cloudflare-connection.ts @@ -0,0 +1,14 @@ +import { AppConnection } from "@app/hooks/api/appConnections/enums"; +import { TRootAppConnection } from "@app/hooks/api/appConnections/types/root-connection"; + +export enum CloudflareConnectionMethod { + ApiToken = "api-token" +} + +export type TCloudflareConnection = TRootAppConnection & { app: AppConnection.Cloudflare } & { + method: CloudflareConnectionMethod.ApiToken; + credentials: { + apiToken: string; + accountId: string; + }; +}; diff --git a/frontend/src/hooks/api/appConnections/types/index.ts b/frontend/src/hooks/api/appConnections/types/index.ts index 413ad0782..5bdc0ac10 100644 --- a/frontend/src/hooks/api/appConnections/types/index.ts +++ b/frontend/src/hooks/api/appConnections/types/index.ts @@ -8,6 +8,7 @@ import { TAzureClientSecretsConnection } from "./azure-client-secrets-connection import { TAzureDevOpsConnection } from "./azure-devops-connection"; import { TAzureKeyVaultConnection } from "./azure-key-vault-connection"; import { TCamundaConnection } from "./camunda-connection"; +import { TCloudflareConnection } from "./cloudflare-connection"; import { TDatabricksConnection } from "./databricks-connection"; import { TFlyioConnection } from "./flyio-connection"; import { TGcpConnection } from "./gcp-connection"; @@ -55,6 +56,7 @@ export * from "./teamcity-connection"; export * from "./terraform-cloud-connection"; export * from "./vercel-connection"; export * from "./windmill-connection"; +export * from "./cloudflare-connection"; export type TAppConnection = | TAwsConnection @@ -83,7 +85,8 @@ export type TAppConnection = | TOnePassConnection | THerokuConnection | TRenderConnection - | TFlyioConnection; + | TFlyioConnection + | TCloudflareConnection; export type TAvailableAppConnection = Pick; @@ -138,4 +141,5 @@ export type TAppConnectionMap = { [AppConnection.Heroku]: THerokuConnection; [AppConnection.Render]: TRenderConnection; [AppConnection.Flyio]: TFlyioConnection; + [AppConnection.Cloudflare]: TCloudflareConnection; }; diff --git a/frontend/src/hooks/api/secretSyncs/enums.ts b/frontend/src/hooks/api/secretSyncs/enums.ts index a59ba20c9..46929b46f 100644 --- a/frontend/src/hooks/api/secretSyncs/enums.ts +++ b/frontend/src/hooks/api/secretSyncs/enums.ts @@ -18,7 +18,8 @@ export enum SecretSync { OnePass = "1password", Heroku = "heroku", Render = "render", - Flyio = "flyio" + Flyio = "flyio", + CloudflarePages = "cloudflare-pages" } export enum SecretSyncStatus { diff --git a/frontend/src/hooks/api/secretSyncs/types/cloudflare-pages-sync.ts b/frontend/src/hooks/api/secretSyncs/types/cloudflare-pages-sync.ts new file mode 100644 index 000000000..14fc268b7 --- /dev/null +++ b/frontend/src/hooks/api/secretSyncs/types/cloudflare-pages-sync.ts @@ -0,0 +1,16 @@ +import { AppConnection } from "@app/hooks/api/appConnections/enums"; +import { SecretSync } from "@app/hooks/api/secretSyncs"; +import { TRootSecretSync } from "@app/hooks/api/secretSyncs/types/root-sync"; + +export type TCloudflarePagesSync = TRootSecretSync & { + destination: SecretSync.CloudflarePages; + destinationConfig: { + projectName: string; + environment: string; + }; + connection: { + app: AppConnection.Cloudflare; + name: string; + id: string; + }; +}; diff --git a/frontend/src/hooks/api/secretSyncs/types/index.ts b/frontend/src/hooks/api/secretSyncs/types/index.ts index aa46b5988..b55f64b23 100644 --- a/frontend/src/hooks/api/secretSyncs/types/index.ts +++ b/frontend/src/hooks/api/secretSyncs/types/index.ts @@ -9,6 +9,7 @@ import { TAzureAppConfigurationSync } from "./azure-app-configuration-sync"; import { TAzureDevOpsSync } from "./azure-devops-sync"; import { TAzureKeyVaultSync } from "./azure-key-vault-sync"; import { TCamundaSync } from "./camunda-sync"; +import { TCloudflarePagesSync } from "./cloudflare-pages-sync"; import { TDatabricksSync } from "./databricks-sync"; import { TFlyioSync } from "./flyio-sync"; import { TGcpSync } from "./gcp-sync"; @@ -49,7 +50,8 @@ export type TSecretSync = | TOnePassSync | THerokuSync | TRenderSync - | TFlyioSync; + | TFlyioSync + | TCloudflarePagesSync; export type TListSecretSyncs = { secretSyncs: TSecretSync[] }; 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 45d550dae..1a90085e4 100644 --- a/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/AppConnectionForm.tsx +++ b/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/AppConnectionForm.tsx @@ -17,6 +17,7 @@ import { AzureClientSecretsConnectionForm } from "./AzureClientSecretsConnection import { AzureDevOpsConnectionForm } from "./AzureDevOpsConnectionForm"; import { AzureKeyVaultConnectionForm } from "./AzureKeyVaultConnectionForm"; import { CamundaConnectionForm } from "./CamundaConnectionForm"; +import { CloudflareConnectionForm } from "./CloudflareConnectionForm"; import { DatabricksConnectionForm } from "./DatabricksConnectionForm"; import { FlyioConnectionForm } from "./FlyioConnectionForm"; import { GcpConnectionForm } from "./GcpConnectionForm"; @@ -128,6 +129,8 @@ const CreateForm = ({ app, onComplete }: CreateFormProps) => { return ; case AppConnection.Flyio: return ; + case AppConnection.Cloudflare: + return ; default: throw new Error(`Unhandled App ${app}`); } @@ -218,6 +221,8 @@ const UpdateForm = ({ appConnection, onComplete }: UpdateFormProps) => { return ; case AppConnection.Flyio: return ; + case AppConnection.Cloudflare: + return ; default: throw new Error(`Unhandled App ${(appConnection as TAppConnection).app}`); } diff --git a/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/CloudflareConnectionForm.tsx b/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/CloudflareConnectionForm.tsx new file mode 100644 index 000000000..df23f520e --- /dev/null +++ b/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/CloudflareConnectionForm.tsx @@ -0,0 +1,152 @@ +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 { CloudflareConnectionMethod, TCloudflareConnection } from "@app/hooks/api/appConnections"; +import { AppConnection } from "@app/hooks/api/appConnections/enums"; + +import { + genericAppConnectionFieldsSchema, + GenericAppConnectionsFields +} from "./GenericAppConnectionFields"; + +type Props = { + appConnection?: TCloudflareConnection; + onSubmit: (formData: FormData) => Promise; +}; + +const rootSchema = genericAppConnectionFieldsSchema.extend({ + app: z.literal(AppConnection.Cloudflare) +}); + +const formSchema = z.discriminatedUnion("method", [ + rootSchema.extend({ + method: z.literal(CloudflareConnectionMethod.ApiToken), + credentials: z.object({ + apiToken: z.string().trim().min(1, "API Token required"), + accountId: z.string().trim().min(1, "Account ID required") + }) + }) +]); + +type FormData = z.infer; + +export const CloudflareConnectionForm = ({ appConnection, onSubmit }: Props) => { + const isUpdate = Boolean(appConnection); + + const form = useForm({ + resolver: zodResolver(formSchema), + defaultValues: appConnection ?? { + app: AppConnection.Cloudflare, + method: CloudflareConnectionMethod.ApiToken + } + }); + + const { + handleSubmit, + control, + formState: { isSubmitting, isDirty } + } = form; + + return ( + +
+ {!isUpdate && } + ( + + + + )} + /> + ( + + onChange(e.target.value)} + placeholder="802fcff12d4340f8e22feb5dd1d6ecac" + /> + + )} + /> + ( + + onChange(e.target.value)} + /> + + )} + /> +
+ + + + +
+ +
+ ); +}; diff --git a/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/SecretSyncDestinationCol/CloudflarePagesSyncDestinationCol.tsx b/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/SecretSyncDestinationCol/CloudflarePagesSyncDestinationCol.tsx new file mode 100644 index 000000000..5bde23796 --- /dev/null +++ b/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/SecretSyncDestinationCol/CloudflarePagesSyncDestinationCol.tsx @@ -0,0 +1,14 @@ +import { TCloudflarePagesSync } from "@app/hooks/api/secretSyncs/types/cloudflare-pages-sync"; + +import { getSecretSyncDestinationColValues } from "../helpers"; +import { SecretSyncTableCell } from "../SecretSyncTableCell"; + +type Props = { + secretSync: TCloudflarePagesSync; +}; + +export const CloudflarePagesSyncDestinationCol = ({ secretSync }: Props) => { + const { primaryText, secondaryText } = getSecretSyncDestinationColValues(secretSync); + + return ; +}; diff --git a/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/SecretSyncDestinationCol/SecretSyncDestinationCol.tsx b/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/SecretSyncDestinationCol/SecretSyncDestinationCol.tsx index 449ee4b58..5eabb7edf 100644 --- a/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/SecretSyncDestinationCol/SecretSyncDestinationCol.tsx +++ b/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/SecretSyncDestinationCol/SecretSyncDestinationCol.tsx @@ -7,6 +7,7 @@ import { AzureAppConfigurationDestinationSyncCol } from "./AzureAppConfiguration import { AzureDevOpsSyncDestinationCol } from "./AzureDevOpsSyncDestinationCol"; import { AzureKeyVaultDestinationSyncCol } from "./AzureKeyVaultDestinationSyncCol"; import { CamundaSyncDestinationCol } from "./CamundaSyncDestinationCol"; +import { CloudflarePagesSyncDestinationCol } from "./CloudflarePagesSyncDestinationCol"; import { DatabricksSyncDestinationCol } from "./DatabricksSyncDestinationCol"; import { FlyioSyncDestinationCol } from "./FlyioSyncDestinationCol"; import { GcpSyncDestinationCol } from "./GcpSyncDestinationCol"; @@ -67,6 +68,8 @@ export const SecretSyncDestinationCol = ({ secretSync }: Props) => { return ; case SecretSync.Flyio: return ; + case SecretSync.CloudflarePages: + return ; default: throw new Error( `Unhandled Secret Sync Destination Col: ${(secretSync as TSecretSync).destination}` diff --git a/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/helpers/index.ts b/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/helpers/index.ts index 3e36cd3bc..54638b96b 100644 --- a/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/helpers/index.ts +++ b/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/helpers/index.ts @@ -128,6 +128,10 @@ export const getSecretSyncDestinationColValues = (secretSync: TSecretSync) => { primaryText = destinationConfig.appId; secondaryText = "App ID"; break; + case SecretSync.CloudflarePages: + primaryText = destinationConfig.projectName; + secondaryText = destinationConfig.environment; + break; default: throw new Error(`Unhandled Destination Col Values ${destination}`); } diff --git a/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncDestinationSection/CloudflarePagesSyncDestinationSection.tsx b/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncDestinationSection/CloudflarePagesSyncDestinationSection.tsx new file mode 100644 index 000000000..f34d8e461 --- /dev/null +++ b/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncDestinationSection/CloudflarePagesSyncDestinationSection.tsx @@ -0,0 +1,19 @@ +import { GenericFieldLabel } from "@app/components/secret-syncs"; +import { TCloudflarePagesSync } from "@app/hooks/api/secretSyncs/types/cloudflare-pages-sync"; + +type Props = { + secretSync: TCloudflarePagesSync; +}; + +export const CloudflarePagesSyncDestinationSection = ({ secretSync }: Props) => { + const { + destinationConfig: { projectName, environment } + } = secretSync; + + return ( + <> + {projectName} + {environment} + + ); +}; diff --git a/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncDestinationSection/SecretSyncDestinatonSection.tsx b/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncDestinationSection/SecretSyncDestinatonSection.tsx index 95a2a9f8e..10b5f1164 100644 --- a/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncDestinationSection/SecretSyncDestinatonSection.tsx +++ b/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncDestinationSection/SecretSyncDestinatonSection.tsx @@ -18,6 +18,7 @@ import { AzureAppConfigurationSyncDestinationSection } from "./AzureAppConfigura import { AzureDevOpsSyncDestinationSection } from "./AzureDevOpsSyncDestinationSection"; import { AzureKeyVaultSyncDestinationSection } from "./AzureKeyVaultSyncDestinationSection"; import { CamundaSyncDestinationSection } from "./CamundaSyncDestinationSection"; +import { CloudflarePagesSyncDestinationSection } from "./CloudflarePagesSyncDestinationSection"; import { DatabricksSyncDestinationSection } from "./DatabricksSyncDestinationSection"; import { FlyioSyncDestinationSection } from "./FlyioSyncDestinationSection"; import { GcpSyncDestinationSection } from "./GcpSyncDestinationSection"; @@ -106,6 +107,9 @@ export const SecretSyncDestinationSection = ({ secretSync, onEditDestination }: case SecretSync.Flyio: DestinationComponents = ; break; + case SecretSync.CloudflarePages: + DestinationComponents = ; + break; default: throw new Error(`Unhandled Destination Section components: ${destination}`); } diff --git a/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncOptionsSection/SecretSyncOptionsSection.tsx b/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncOptionsSection/SecretSyncOptionsSection.tsx index a76a20e3e..e560196fc 100644 --- a/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncOptionsSection/SecretSyncOptionsSection.tsx +++ b/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncOptionsSection/SecretSyncOptionsSection.tsx @@ -58,6 +58,7 @@ export const SecretSyncOptionsSection = ({ secretSync, onEditOptions }: Props) = case SecretSync.Heroku: case SecretSync.Render: case SecretSync.Flyio: + case SecretSync.CloudflarePages: AdditionalSyncOptionsComponent = null; break; default: