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 index d18d0564e..dea917947 100644 --- 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 @@ -46,7 +46,6 @@ export const registerCloudflareConnectionRouter = async (server: FastifyZodProvi const { connectionId } = req.params; const projects = await server.services.appConnection.cloudflare.listPagesProjects(connectionId, req.permission); - return projects; } }); @@ -73,9 +72,36 @@ export const registerCloudflareConnectionRouter = async (server: FastifyZodProvi handler: async (req) => { const { connectionId } = req.params; - const projects = await server.services.appConnection.cloudflare.listWorkersScripts(connectionId, req.permission); + const scripts = await server.services.appConnection.cloudflare.listWorkersScripts(connectionId, req.permission); + return scripts; + } + }); - return projects; + server.route({ + method: "GET", + url: `/:connectionId/cloudflare-zones`, + 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 zones = await server.services.appConnection.cloudflare.listZones(connectionId, req.permission); + return zones; } }); }; diff --git a/backend/src/services/app-connection/cloudflare/cloudflare-connection-fns.ts b/backend/src/services/app-connection/cloudflare/cloudflare-connection-fns.ts index d0ac070f3..02f304a7f 100644 --- a/backend/src/services/app-connection/cloudflare/cloudflare-connection-fns.ts +++ b/backend/src/services/app-connection/cloudflare/cloudflare-connection-fns.ts @@ -10,7 +10,8 @@ import { TCloudflareConnection, TCloudflareConnectionConfig, TCloudflarePagesProject, - TCloudflareWorkersScript + TCloudflareWorkersScript, + TCloudflareZone } from "./cloudflare-connection-types"; export const getCloudflareConnectionListItem = () => { @@ -66,6 +67,27 @@ export const listCloudflareWorkersScripts = async ( })); }; +export const listCloudflareZones = async (appConnection: TCloudflareConnection): Promise => { + const { + credentials: { apiToken } + } = appConnection; + + const { data } = await request.get<{ result: { name: string; id: string }[] }>( + `${IntegrationUrls.CLOUDFLARE_API_URL}/client/v4/zones`, + { + 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; diff --git a/backend/src/services/app-connection/cloudflare/cloudflare-connection-service.ts b/backend/src/services/app-connection/cloudflare/cloudflare-connection-service.ts index 5a8a161fc..c832ddb90 100644 --- a/backend/src/services/app-connection/cloudflare/cloudflare-connection-service.ts +++ b/backend/src/services/app-connection/cloudflare/cloudflare-connection-service.ts @@ -2,7 +2,11 @@ import { logger } from "@app/lib/logger"; import { OrgServiceActor } from "@app/lib/types"; import { AppConnection } from "../app-connection-enums"; -import { listCloudflarePagesProjects, listCloudflareWorkersScripts } from "./cloudflare-connection-fns"; +import { + listCloudflarePagesProjects, + listCloudflareWorkersScripts, + listCloudflareZones +} from "./cloudflare-connection-fns"; import { TCloudflareConnection } from "./cloudflare-connection-types"; type TGetAppConnectionFunc = ( @@ -16,7 +20,6 @@ export const cloudflareConnectionService = (getAppConnection: TGetAppConnectionF const appConnection = await getAppConnection(AppConnection.Cloudflare, connectionId, actor); try { const projects = await listCloudflarePagesProjects(appConnection); - return projects; } catch (error) { logger.error( @@ -30,9 +33,8 @@ export const cloudflareConnectionService = (getAppConnection: TGetAppConnectionF const listWorkersScripts = async (connectionId: string, actor: OrgServiceActor) => { const appConnection = await getAppConnection(AppConnection.Cloudflare, connectionId, actor); try { - const projects = await listCloudflareWorkersScripts(appConnection); - - return projects; + const scripts = await listCloudflareWorkersScripts(appConnection); + return scripts; } catch (error) { logger.error( error, @@ -42,8 +44,20 @@ export const cloudflareConnectionService = (getAppConnection: TGetAppConnectionF } }; + const listZones = async (connectionId: string, actor: OrgServiceActor) => { + const appConnection = await getAppConnection(AppConnection.Cloudflare, connectionId, actor); + try { + const zones = await listCloudflareZones(appConnection); + return zones; + } catch (error) { + logger.error(error, `Failed to list Cloudflare Zones for Cloudflare connection [connectionId=${connectionId}]`); + return []; + } + }; + return { listPagesProjects, - listWorkersScripts + listWorkersScripts, + listZones }; }; diff --git a/backend/src/services/app-connection/cloudflare/cloudflare-connection-types.ts b/backend/src/services/app-connection/cloudflare/cloudflare-connection-types.ts index 0ac1b708c..163bf6495 100644 --- a/backend/src/services/app-connection/cloudflare/cloudflare-connection-types.ts +++ b/backend/src/services/app-connection/cloudflare/cloudflare-connection-types.ts @@ -32,3 +32,8 @@ export type TCloudflarePagesProject = { export type TCloudflareWorkersScript = { id: string; }; + +export type TCloudflareZone = { + id: string; + name: string; +}; diff --git a/backend/src/services/certificate-authority/acme/acme-certificate-authority-enums.ts b/backend/src/services/certificate-authority/acme/acme-certificate-authority-enums.ts index 9f6fbe752..c4703d49f 100644 --- a/backend/src/services/certificate-authority/acme/acme-certificate-authority-enums.ts +++ b/backend/src/services/certificate-authority/acme/acme-certificate-authority-enums.ts @@ -1,3 +1,4 @@ export enum AcmeDnsProvider { - Route53 = "route53" + Route53 = "route53", + Cloudflare = "cloudflare" } diff --git a/backend/src/services/certificate-authority/acme/acme-certificate-authority-fns.ts b/backend/src/services/certificate-authority/acme/acme-certificate-authority-fns.ts index f6e77ac8e..86beadffe 100644 --- a/backend/src/services/certificate-authority/acme/acme-certificate-authority-fns.ts +++ b/backend/src/services/certificate-authority/acme/acme-certificate-authority-fns.ts @@ -1,19 +1,17 @@ -import { ChangeResourceRecordSetsCommand, Route53Client } from "@aws-sdk/client-route-53"; import * as x509 from "@peculiar/x509"; import acme from "acme-client"; import { TableName } from "@app/db/schemas"; -import { CustomAWSHasher } from "@app/lib/aws/hashing"; import { crypto } from "@app/lib/crypto/cryptography"; import { BadRequestError, CryptographyError, NotFoundError } from "@app/lib/errors"; import { OrgServiceActor } from "@app/lib/types"; import { blockLocalAndPrivateIpAddresses } from "@app/lib/validator"; import { TAppConnectionDALFactory } from "@app/services/app-connection/app-connection-dal"; -import { AppConnection, AWSRegion } from "@app/services/app-connection/app-connection-enums"; +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; import { decryptAppConnection } from "@app/services/app-connection/app-connection-fns"; import { TAppConnectionServiceFactory } from "@app/services/app-connection/app-connection-service"; -import { getAwsConnectionConfig } from "@app/services/app-connection/aws/aws-connection-fns"; -import { TAwsConnection, TAwsConnectionConfig } from "@app/services/app-connection/aws/aws-connection-types"; +import { TAwsConnection } from "@app/services/app-connection/aws/aws-connection-types"; +import { TCloudflareConnection } from "@app/services/app-connection/cloudflare/cloudflare-connection-types"; import { TCertificateBodyDALFactory } from "@app/services/certificate/certificate-body-dal"; import { TCertificateDALFactory } from "@app/services/certificate/certificate-dal"; import { TCertificateSecretDALFactory } from "@app/services/certificate/certificate-secret-dal"; @@ -39,6 +37,8 @@ import { TCreateAcmeCertificateAuthorityDTO, TUpdateAcmeCertificateAuthorityDTO } from "./acme-certificate-authority-types"; +import { cloudflareDeleteTxtRecord, cloudflareInsertTxtRecord } from "./dns-providers/cloudflare"; +import { route53DeleteTxtRecord, route53InsertTxtRecord } from "./dns-providers/route54"; type TAcmeCertificateAuthorityFnsDeps = { appConnectionDAL: Pick; @@ -95,74 +95,6 @@ export const castDbEntryToAcmeCertificateAuthority = ( }; }; -export const route53InsertTxtRecord = async ( - connection: TAwsConnectionConfig, - hostedZoneId: string, - domain: string, - value: string -) => { - const config = await getAwsConnectionConfig(connection, AWSRegion.US_WEST_1); // REGION is irrelevant because Route53 is global - const route53Client = new Route53Client({ - sha256: CustomAWSHasher, - useFipsEndpoint: crypto.isFipsModeEnabled(), - credentials: config.credentials!, - region: config.region - }); - - const command = new ChangeResourceRecordSetsCommand({ - HostedZoneId: hostedZoneId, - ChangeBatch: { - Comment: "Set ACME challenge TXT record", - Changes: [ - { - Action: "UPSERT", - ResourceRecordSet: { - Name: domain, - Type: "TXT", - TTL: 30, - ResourceRecords: [{ Value: value }] - } - } - ] - } - }); - - await route53Client.send(command); -}; - -export const route53DeleteTxtRecord = async ( - connection: TAwsConnectionConfig, - hostedZoneId: string, - domain: string, - value: string -) => { - const config = await getAwsConnectionConfig(connection, AWSRegion.US_WEST_1); // REGION is irrelevant because Route53 is global - const route53Client = new Route53Client({ - credentials: config.credentials!, - region: config.region - }); - - const command = new ChangeResourceRecordSetsCommand({ - HostedZoneId: hostedZoneId, - ChangeBatch: { - Comment: "Delete ACME challenge TXT record", - Changes: [ - { - Action: "DELETE", - ResourceRecordSet: { - Name: domain, - Type: "TXT", - TTL: 30, - ResourceRecords: [{ Value: value }] - } - } - ] - } - }); - - await route53Client.send(command); -}; - export const AcmeCertificateAuthorityFns = ({ appConnectionDAL, appConnectionService, @@ -209,6 +141,12 @@ export const AcmeCertificateAuthorityFns = ({ }); } + if (dnsProviderConfig.provider === AcmeDnsProvider.Cloudflare && appConnection.app !== AppConnection.Cloudflare) { + throw new BadRequestError({ + message: `App connection with ID '${dnsAppConnectionId}' is not a Cloudflare connection` + }); + } + // validates permission to connect await appConnectionService.connectAppConnectionById(appConnection.app as AppConnection, dnsAppConnectionId, actor); @@ -289,6 +227,15 @@ export const AcmeCertificateAuthorityFns = ({ }); } + if ( + dnsProviderConfig.provider === AcmeDnsProvider.Cloudflare && + appConnection.app !== AppConnection.Cloudflare + ) { + throw new BadRequestError({ + message: `App connection with ID '${dnsAppConnectionId}' is not a Cloudflare connection` + }); + } + // validates permission to connect await appConnectionService.connectAppConnectionById( appConnection.app as AppConnection, @@ -443,26 +390,56 @@ export const AcmeCertificateAuthorityFns = ({ const recordName = `_acme-challenge.${authz.identifier.value}`; // e.g., "_acme-challenge.example.com" const recordValue = `"${keyAuthorization}"`; // must be double quoted - if (acmeCa.configuration.dnsProviderConfig.provider === AcmeDnsProvider.Route53) { - await route53InsertTxtRecord( - connection as TAwsConnection, - acmeCa.configuration.dnsProviderConfig.hostedZoneId, - recordName, - recordValue - ); + switch (acmeCa.configuration.dnsProviderConfig.provider) { + case AcmeDnsProvider.Route53: { + await route53InsertTxtRecord( + connection as TAwsConnection, + acmeCa.configuration.dnsProviderConfig.hostedZoneId, + recordName, + recordValue + ); + break; + } + case AcmeDnsProvider.Cloudflare: { + await cloudflareInsertTxtRecord( + connection as TCloudflareConnection, + acmeCa.configuration.dnsProviderConfig.hostedZoneId, + recordName, + recordValue + ); + break; + } + default: { + throw new Error(`Unsupported DNS provider: ${acmeCa.configuration.dnsProviderConfig.provider as string}`); + } } }, challengeRemoveFn: async (authz, challenge, keyAuthorization) => { const recordName = `_acme-challenge.${authz.identifier.value}`; // e.g., "_acme-challenge.example.com" const recordValue = `"${keyAuthorization}"`; // must be double quoted - if (acmeCa.configuration.dnsProviderConfig.provider === AcmeDnsProvider.Route53) { - await route53DeleteTxtRecord( - connection as TAwsConnection, - acmeCa.configuration.dnsProviderConfig.hostedZoneId, - recordName, - recordValue - ); + switch (acmeCa.configuration.dnsProviderConfig.provider) { + case AcmeDnsProvider.Route53: { + await route53DeleteTxtRecord( + connection as TAwsConnection, + acmeCa.configuration.dnsProviderConfig.hostedZoneId, + recordName, + recordValue + ); + break; + } + case AcmeDnsProvider.Cloudflare: { + await cloudflareDeleteTxtRecord( + connection as TCloudflareConnection, + acmeCa.configuration.dnsProviderConfig.hostedZoneId, + recordName, + recordValue + ); + break; + } + default: { + throw new Error(`Unsupported DNS provider: ${acmeCa.configuration.dnsProviderConfig.provider as string}`); + } } } }); diff --git a/backend/src/services/certificate-authority/acme/dns-providers/cloudflare.ts b/backend/src/services/certificate-authority/acme/dns-providers/cloudflare.ts new file mode 100644 index 000000000..f4b12e657 --- /dev/null +++ b/backend/src/services/certificate-authority/acme/dns-providers/cloudflare.ts @@ -0,0 +1,109 @@ +import axios from "axios"; + +import { request } from "@app/lib/config/request"; +import { TCloudflareConnectionConfig } from "@app/services/app-connection/cloudflare/cloudflare-connection-types"; +import { IntegrationUrls } from "@app/services/integration-auth/integration-list"; + +export const cloudflareInsertTxtRecord = async ( + connection: TCloudflareConnectionConfig, + hostedZoneId: string, + domain: string, + value: string +) => { + const { + credentials: { apiToken } + } = connection; + + try { + await request.post( + `${IntegrationUrls.CLOUDFLARE_API_URL}/client/v4/zones/${encodeURIComponent(hostedZoneId)}/dns_records`, + { + type: "TXT", + name: domain, + content: value, + ttl: 60, + proxied: false + }, + { + headers: { + Authorization: `Bearer ${apiToken}`, + "Content-Type": "application/json", + Accept: "application/json" + } + } + ); + } catch (error) { + if (axios.isAxiosError(error)) { + const firstErrorMessage = ( + error.response?.data as { + errors?: { message: string }[]; + } + )?.errors?.[0]?.message; + if (firstErrorMessage) { + throw new Error(firstErrorMessage); + } + } + throw error; + } +}; + +export const cloudflareDeleteTxtRecord = async ( + connection: TCloudflareConnectionConfig, + hostedZoneId: string, + domain: string, + value: string +) => { + const { + credentials: { apiToken } + } = connection; + + try { + const listRecordsResponse = await request.get<{ + result: { id: string; type: string; name: string; content: string }[]; + }>(`${IntegrationUrls.CLOUDFLARE_API_URL}/client/v4/zones/${encodeURIComponent(hostedZoneId)}/dns_records`, { + headers: { + Authorization: `Bearer ${apiToken}`, + "Content-Type": "application/json", + Accept: "application/json" + }, + params: { + type: "TXT", + name: domain, + content: value + } + }); + + const dnsRecords = listRecordsResponse.data?.result; + + if (Array.isArray(dnsRecords) && dnsRecords.length > 0) { + const recordToDelete = dnsRecords.find( + (record) => record.type === "TXT" && record.name === domain && record.content === value + ); + + if (recordToDelete) { + await request.delete( + `${IntegrationUrls.CLOUDFLARE_API_URL}/client/v4/zones/${encodeURIComponent(hostedZoneId)}/dns_records/${recordToDelete.id}`, + { + headers: { + Authorization: `Bearer ${apiToken}`, + "Content-Type": "application/json", + Accept: "application/json" + } + } + ); + } + } + } catch (error) { + if (axios.isAxiosError(error)) { + const firstErrorMessage = ( + error.response?.data as { + errors?: { message: string }[]; + } + )?.errors?.[0]?.message; + if (firstErrorMessage) { + throw new Error(firstErrorMessage); + } + } + throw error; + } +}; diff --git a/backend/src/services/certificate-authority/acme/dns-providers/route54.ts b/backend/src/services/certificate-authority/acme/dns-providers/route54.ts new file mode 100644 index 000000000..4d235a6e0 --- /dev/null +++ b/backend/src/services/certificate-authority/acme/dns-providers/route54.ts @@ -0,0 +1,75 @@ +import { ChangeResourceRecordSetsCommand, Route53Client } from "@aws-sdk/client-route-53"; + +import { CustomAWSHasher } from "@app/lib/aws/hashing"; +import { crypto } from "@app/lib/crypto/cryptography"; +import { AWSRegion } from "@app/services/app-connection/app-connection-enums"; +import { getAwsConnectionConfig } from "@app/services/app-connection/aws/aws-connection-fns"; +import { TAwsConnectionConfig } from "@app/services/app-connection/aws/aws-connection-types"; + +export const route53InsertTxtRecord = async ( + connection: TAwsConnectionConfig, + hostedZoneId: string, + domain: string, + value: string +) => { + const config = await getAwsConnectionConfig(connection, AWSRegion.US_WEST_1); // REGION is irrelevant because Route53 is global + const route53Client = new Route53Client({ + sha256: CustomAWSHasher, + useFipsEndpoint: crypto.isFipsModeEnabled(), + credentials: config.credentials!, + region: config.region + }); + + const command = new ChangeResourceRecordSetsCommand({ + HostedZoneId: hostedZoneId, + ChangeBatch: { + Comment: "Set ACME challenge TXT record", + Changes: [ + { + Action: "UPSERT", + ResourceRecordSet: { + Name: domain, + Type: "TXT", + TTL: 30, + ResourceRecords: [{ Value: value }] + } + } + ] + } + }); + + await route53Client.send(command); +}; + +export const route53DeleteTxtRecord = async ( + connection: TAwsConnectionConfig, + hostedZoneId: string, + domain: string, + value: string +) => { + const config = await getAwsConnectionConfig(connection, AWSRegion.US_WEST_1); // REGION is irrelevant because Route53 is global + const route53Client = new Route53Client({ + credentials: config.credentials!, + region: config.region + }); + + const command = new ChangeResourceRecordSetsCommand({ + HostedZoneId: hostedZoneId, + ChangeBatch: { + Comment: "Delete ACME challenge TXT record", + Changes: [ + { + Action: "DELETE", + ResourceRecordSet: { + Name: domain, + Type: "TXT", + TTL: 30, + ResourceRecords: [{ Value: value }] + } + } + ] + } + }); + + await route53Client.send(command); +}; diff --git a/docs/documentation/platform/pki/acme-ca.mdx b/docs/documentation/platform/pki/acme-ca.mdx index 495d20917..8b1fa10db 100644 --- a/docs/documentation/platform/pki/acme-ca.mdx +++ b/docs/documentation/platform/pki/acme-ca.mdx @@ -14,7 +14,7 @@ ACME is a protocol that automates the process of certificate issuance and renewa ```mermaid graph TD A[ACME CA Provider
e.g., Let's Encrypt] <-->|ACME v2 Protocol| B[Infisical] - B -->|Creates TXT Records
via Route53| C[DNS Validation] + B -->|Creates TXT Records
via Route53/Cloudflare| C[DNS Validation] B -->|Manages Certificates| D[Subscribers] ``` @@ -28,8 +28,8 @@ We recommend reading about [ACME protocol](https://tools.ietf.org/html/rfc8555) A typical workflow for using Infisical with ACME Certificate Authorities consists of the following steps: -1. Setting up AWS Route53 credentials with appropriate DNS permissions. -2. Creating an AWS connection in Infisical to store the Route53 credentials. +1. Setting up AWS Route53 or Cloudflare credentials with appropriate DNS permissions. +2. Creating an AWS/Cloudflare connection in Infisical to store the credentials. 3. Registering an ACME Certificate Authority (like Let's Encrypt) with Infisical. 4. Creating subscribers that use the ACME CA as their issuing authority. 5. Managing certificate lifecycle events such as issuance, renewal, and revocation through Infisical. @@ -55,59 +55,75 @@ This automated process eliminates the need for manual intervention in domain val In the following steps, we explore how to set up ACME Certificate Authority integration with Infisical using Let's Encrypt as an example. - - Before proceeding with the ACME CA registration, you need to set up an AWS connection with the appropriate permissions for DNS validation: + + Before proceeding with the ACME CA registration, you need to set up an App Connection with the appropriate permissions for DNS validation: - 1. Navigate to your Organization Settings > App Connections and create a new AWS connection. + + + 1. Navigate to your Organization Settings > App Connections and create a new AWS connection. - 2. Ensure your AWS connection has the following minimum permissions for Route53 DNS validation: + 2. Ensure your AWS connection has the following minimum permissions for Route53 DNS validation: - ```json - { - "Version": "2012-10-17", - "Statement": [ - { - "Effect": "Allow", - "Action": "route53:GetChange", - "Resource": "arn:aws:route53:::change/*" - }, - { - "Effect": "Allow", - "Action": "route53:ListHostedZonesByName", - "Resource": "*" - }, - { - "Effect": "Allow", - "Action": [ - "route53:ListResourceRecordSets" - ], - "Resource": [ - "arn:aws:route53:::hostedzone/YOUR_HOSTED_ZONE_ID" - ] - }, - { - "Effect": "Allow", - "Action": [ - "route53:ChangeResourceRecordSets" - ], - "Resource": [ - "arn:aws:route53:::hostedzone/YOUR_HOSTED_ZONE_ID" - ], - "Condition": { - "ForAllValues:StringEquals": { - "route53:ChangeResourceRecordSetsRecordTypes": [ - "TXT" + ```json + { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": "route53:GetChange", + "Resource": "arn:aws:route53:::change/*" + }, + { + "Effect": "Allow", + "Action": "route53:ListHostedZonesByName", + "Resource": "*" + }, + { + "Effect": "Allow", + "Action": [ + "route53:ListResourceRecordSets" + ], + "Resource": [ + "arn:aws:route53:::hostedzone/YOUR_HOSTED_ZONE_ID" + ] + }, + { + "Effect": "Allow", + "Action": [ + "route53:ChangeResourceRecordSets" + ], + "Resource": [ + "arn:aws:route53:::hostedzone/YOUR_HOSTED_ZONE_ID" + ], + "Condition": { + "ForAllValues:StringEquals": { + "route53:ChangeResourceRecordSetsRecordTypes": [ + "TXT" + ] + } + } + } ] } - } - } - ] - } - ``` + ``` - Replace `YOUR_HOSTED_ZONE_ID` with your actual Route53 hosted zone ID. + Replace `YOUR_HOSTED_ZONE_ID` with your actual Route53 hosted zone ID. - For detailed instructions on setting up an AWS connection, see the [AWS Connection](/integrations/app-connections/aws) documentation. + For detailed instructions on setting up an AWS connection, see the [AWS Connection](/integrations/app-connections/aws) documentation. + + + 1. Navigate to your Organization Settings > App Connections and create a new Cloudflare connection. + + 2. Ensure your Cloudflare token has the following minimum permissions for DNS validation: + + ``` + Account:Account Settings:Read + Zone:DNS:Edit + ``` + + For detailed instructions on setting up a Cloudflare connection, see the [Cloudflare Connection](/integrations/app-connections/cloudflare) documentation. + + @@ -127,7 +143,7 @@ In the following steps, we explore how to set up ACME Certificate Authority inte - **Type**: Select "ACME" as the External CA type. - **Name**: Enter a name for the ACME CA (e.g., "lets-encrypt-production"). - **DNS App Connection**: Select from available DNS app connections or configure a new one. This connection provides Infisical with the credentials needed to create and remove DNS records for ACME validation. - - **Hosted Zone ID**: Enter your Route53 hosted zone ID (e.g., Z04044I124N1GOOMCOYX1) for the domain(s) you'll be requesting certificates for. + - **Zone ID**: Enter the Zone ID for the domain(s) you'll be requesting certificates for. - **Directory URL**: Enter the ACME v2 directory URL for your chosen CA provider (e.g., `https://acme-v02.api.letsencrypt.org/directory` for Let's Encrypt). - **Account Email**: Email address to associate with your ACME account. This email will receive important notifications about your certificates. - **Enable Direct Issuance**: Toggle on to allow direct certificate issuance without requiring subscribers. @@ -140,7 +156,7 @@ In the following steps, we explore how to set up ACME Certificate Authority inte ![pki external ca list](/images/platform/pki/ca/external-ca/external-ca-list.png) From here, you can: - + - View the status of the ACME CA registration - Edit the configuration settings - Disable or re-enable the ACME CA @@ -152,9 +168,9 @@ In the following steps, we explore how to set up ACME Certificate Authority inte To register an ACME CA with Infisical using the API, make a request to the Create External CA endpoint: - + ### Sample request - + ```bash Request curl 'https://app.infisical.com/api/v1/pki/ca/acme' \ -H 'Authorization: Bearer ' \ @@ -180,9 +196,9 @@ In the following steps, we explore how to set up ACME Certificate Authority inte } }' ``` - + ### Sample response - + ```bash Response { "id": "c48b701e-a20c-4a9a-8119-68f54e5fbb05", @@ -226,7 +242,7 @@ In the following steps, we explore how to set up ACME Certificate Authority inte 1. Infisical generates a key pair for the certificate 2. Sends a Certificate Signing Request (CSR) to the ACME CA 3. Receives a DNS-01 challenge from the ACME provider - 4. Creates a TXT record in Route53 to satisfy the challenge + 4. Creates a TXT record in Route53/Cloudflare to satisfy the challenge 5. Notifies the ACME provider that the challenge is ready for validation 6. Once validated, the ACME provider issues the certificate 7. Infisical stores and manages the certificate for your subscriber @@ -235,7 +251,7 @@ In the following steps, we explore how to set up ACME Certificate Authority inte The issued certificate and private key are now available through Infisical and can be: - + - Downloaded directly from the Infisical UI - Retrieved via the Infisical API for programmatic access using the [latest certificate bundle endpoint](/api-reference/endpoints/pki/subscribers/get-latest-cert-bundle) @@ -265,35 +281,35 @@ Let's Encrypt is a free, automated, and open Certificate Authority that provides - Currently, Infisical supports DNS-01 validation through AWS Route53. The DNS-01 challenge method is preferred for ACME integrations because it: - + Currently, Infisical supports DNS-01 validation through AWS Route53 or Cloudflare. The DNS-01 challenge method is preferred for ACME integrations because it: + - Works with wildcard certificates - Doesn't require your servers to be publicly accessible - Can be fully automated without manual intervention - + Support for additional DNS providers is planned for future releases. Yes! ACME CAs like Let's Encrypt support wildcard certificates (e.g., `*.example.com`) when using DNS-01 validation. Simply specify the wildcard domain in your subscriber configuration. - + Note that wildcard certificates still require DNS-01 validation - HTTP-01 validation cannot be used for wildcard certificates. Most ACME providers issue certificates with 90-day validity periods. This shorter validity period is designed to: - + - Encourage automation of certificate management - Reduce the impact of compromised certificates - Ensure systems stay up-to-date with certificate management practices - + When configured, Infisical automatically handles certificate renewal for subscribers. Yes! You can register multiple ACME CAs in the same project: - + - Different providers for different domains or use cases - Staging and production environments for the same provider - Backup providers for redundancy - + Each subscriber can be configured to use a specific ACME CA based on your requirements. - \ No newline at end of file + diff --git a/docs/documentation/platform/pki/external-ca.mdx b/docs/documentation/platform/pki/external-ca.mdx index 02285cac6..efe029149 100644 --- a/docs/documentation/platform/pki/external-ca.mdx +++ b/docs/documentation/platform/pki/external-ca.mdx @@ -13,11 +13,11 @@ In addition to creating a Private CA hierarchy, Infisical allows you to integrat ```mermaid graph TD B[Infisical] -->|Manages Certificates| D[Subscribers] - + A1[Public CAs
Let's Encrypt, ZeroSSL] -->|ACME Protocol| B A2[Enterprise CAs
Vault PKI, Step CA] -->|ACME Protocol| B A3[Cloud CAs
ACME-compatible services] -->|ACME Protocol| B - + A4[Future: Enterprise CAs] -.->|EST/SCEP Protocols| B A5[Future: Cloud CAs] -.->|REST APIs| B ``` @@ -54,7 +54,7 @@ ACME (Automatic Certificate Management Environment) is a widely adopted protocol **Public Certificate Authorities:** - Let's Encrypt - Free, automated SSL/TLS certificates -- ZeroSSL - Free and premium SSL certificates +- ZeroSSL - Free and premium SSL certificates - Buypass - Norwegian CA with free ACME certificates **Enterprise Certificate Authorities:** @@ -73,7 +73,7 @@ External CA integration is ideal for various scenarios: ### Public-Facing Services Use publicly trusted CAs for websites and services that need browser compatibility: - Web applications and APIs -- Load balancers and CDNs +- Load balancers and CDNs - Public-facing microservices ### Compliance Requirements @@ -137,18 +137,18 @@ Get started with External CA integration: Currently, Infisical supports any Certificate Authority that implements the ACME protocol, including: - + - **Public CAs**: Let's Encrypt, ZeroSSL, Buypass - - **Enterprise CAs**: HashiCorp Vault PKI, Step CA + - **Enterprise CAs**: HashiCorp Vault PKI, Step CA - **Cloud CAs**: ACME-compatible managed services - - Integration uses DNS-01 validation through Route53. Learn more about [supported DNS validation methods](/documentation/platform/pki/acme-ca#what-dns-validation-methods-are-supported). - + + Integration uses DNS-01 validation through Route53 or Cloudflare. Learn more about [supported DNS validation methods](/documentation/platform/pki/acme-ca#what-dns-validation-methods-are-supported). + Support for additional integration protocols (EST, SCEP, direct APIs) is planned for future releases. Yes. You can have both Private CAs (root and intermediate) and External CAs in the same project, allowing you flexibility in how you issue certificates for different use cases. This hybrid approach enables you to: - + - Use Private CAs for internal services and applications - Use External CAs for public-facing services - Apply consistent management practices across all certificate types @@ -156,37 +156,37 @@ Get started with External CA integration: The types of certificates you can issue depend on the External CA provider and type: - + - **Public CAs**: Typically support Domain Validation (DV) certificates, with some offering Organization Validation (OV) - **Enterprise CAs**: Support internal certificates, device certificates, and custom certificate types - **Cloud CAs**: Support various certificate types depending on the service - + Certificate capabilities vary by provider and integration method. Certificate reissuance is handled automatically by Infisical based on the CA type: - + - **Public CAs**: Automatic reissuance using ACME protocol with the same certificate extensions before expiration - **Other CA types**: Certificate management methods depend on the specific integration (when available) - + All certificate lifecycle events are tracked and managed through Infisical's unified interface, ensuring continuous certificate validity. Authentication methods vary by CA type: - + - **Public CAs**: ACME account registration with email and account keys - **Enterprise CAs**: Client certificates, username/password, or domain authentication (when available) - **Cloud CAs**: API keys, OAuth tokens, or service account authentication (when available) - + Infisical securely stores and manages all authentication credentials. Yes, Infisical provides policy enforcement capabilities: - + - Certificate template constraints - Monitoring and alerting policies - Access controls for certificate operations - + These policies ensure consistent governance across both internal and external certificate sources. - \ No newline at end of file + diff --git a/docs/images/app-connections/cloudflare/cloudflare-dns-configure-permissions.png b/docs/images/app-connections/cloudflare/cloudflare-dns-configure-permissions.png new file mode 100644 index 000000000..d0ba4e68d Binary files /dev/null and b/docs/images/app-connections/cloudflare/cloudflare-dns-configure-permissions.png differ diff --git a/docs/integrations/app-connections/cloudflare.mdx b/docs/integrations/app-connections/cloudflare.mdx index 6d79d2567..241c737bc 100644 --- a/docs/integrations/app-connections/cloudflare.mdx +++ b/docs/integrations/app-connections/cloudflare.mdx @@ -50,6 +50,17 @@ Infisical supports connecting to Cloudflare using API tokens and Account ID for + + Use the following permissions to grant Infisical access to verify certificates using DNS TXT records with ACME: + + ![Configure Token](/images/app-connections/cloudflare/cloudflare-dns-configure-permissions.png) + + **Required Permissions:** + - **Account** - **Account Settings** - **Read** + - **Zone** - **DNS** - **Edit** + + Add these permissions to your API token and click **Continue to summary**, then **Create Token** to generate your API token. +
diff --git a/frontend/src/hooks/api/appConnections/cloudflare/queries.tsx b/frontend/src/hooks/api/appConnections/cloudflare/queries.tsx index f4ca87ad1..973943d1e 100644 --- a/frontend/src/hooks/api/appConnections/cloudflare/queries.tsx +++ b/frontend/src/hooks/api/appConnections/cloudflare/queries.tsx @@ -3,14 +3,16 @@ import { useQuery, UseQueryOptions } from "@tanstack/react-query"; import { apiRequest } from "@app/config/request"; import { appConnectionKeys } from "../queries"; -import { TCloudflarePagesProject, TCloudflareWorkersScript } from "./types"; +import { TCloudflarePagesProject, TCloudflareWorkersScript, TCloudflareZone } from "./types"; const cloudflareConnectionKeys = { all: [...appConnectionKeys.all, "cloudflare"] as const, listPagesProjects: (connectionId: string) => [...cloudflareConnectionKeys.all, "pages-projects", connectionId] as const, listWorkersScripts: (connectionId: string) => - [...cloudflareConnectionKeys.all, "workers-scripts", connectionId] as const + [...cloudflareConnectionKeys.all, "workers-scripts", connectionId] as const, + listZones: (connectionId: string) => + [...cloudflareConnectionKeys.all, "zones", connectionId] as const }; export const useCloudflareConnectionListPagesProjects = ( @@ -62,3 +64,28 @@ export const useCloudflareConnectionListWorkersScripts = ( ...options }); }; + +export const useCloudflareConnectionListZones = ( + connectionId: string, + options?: Omit< + UseQueryOptions< + TCloudflareZone[], + unknown, + TCloudflareZone[], + ReturnType + >, + "queryKey" | "queryFn" + > +) => { + return useQuery({ + queryKey: cloudflareConnectionKeys.listZones(connectionId), + queryFn: async () => { + const { data } = await apiRequest.get( + `/api/v1/app-connections/cloudflare/${connectionId}/cloudflare-zones` + ); + + return data; + }, + ...options + }); +}; diff --git a/frontend/src/hooks/api/appConnections/cloudflare/types.ts b/frontend/src/hooks/api/appConnections/cloudflare/types.ts index 1d5ea53d4..e8905e16c 100644 --- a/frontend/src/hooks/api/appConnections/cloudflare/types.ts +++ b/frontend/src/hooks/api/appConnections/cloudflare/types.ts @@ -6,3 +6,8 @@ export type TCloudflarePagesProject = { export type TCloudflareWorkersScript = { id: string; }; + +export type TCloudflareZone = { + id: string; + name: string; +}; diff --git a/frontend/src/hooks/api/ca/constants.tsx b/frontend/src/hooks/api/ca/constants.tsx index 6d05b5ef3..fa59b3ae0 100644 --- a/frontend/src/hooks/api/ca/constants.tsx +++ b/frontend/src/hooks/api/ca/constants.tsx @@ -1,6 +1,7 @@ +import { AppConnection } from "../appConnections/enums"; import { SshCaStatus } from "../sshCa"; import { SshCertTemplateStatus } from "../sshCertificateTemplates"; -import { CaStatus, InternalCaType } from "./enums"; +import { AcmeDnsProvider, CaStatus, InternalCaType } from "./enums"; export const caTypeToNameMap: { [K in InternalCaType]: string } = { [InternalCaType.ROOT]: "Root", @@ -13,6 +14,16 @@ export const caStatusToNameMap: { [K in CaStatus]: string } = { [CaStatus.PENDING_CERTIFICATE]: "Pending Certificate" }; +export const ACME_DNS_PROVIDER_NAME_MAP: Record = { + [AcmeDnsProvider.ROUTE53]: "Route53", + [AcmeDnsProvider.Cloudflare]: "Cloudflare" +}; + +export const ACME_DNS_PROVIDER_APP_CONNECTION_MAP: Record = { + [AcmeDnsProvider.ROUTE53]: AppConnection.AWS, + [AcmeDnsProvider.Cloudflare]: AppConnection.Cloudflare +}; + export const getCaStatusBadgeVariant = (status: CaStatus | SshCaStatus | SshCertTemplateStatus) => { switch (status) { case CaStatus.ACTIVE: diff --git a/frontend/src/hooks/api/ca/enums.tsx b/frontend/src/hooks/api/ca/enums.tsx index b2b725612..b010faa07 100644 --- a/frontend/src/hooks/api/ca/enums.tsx +++ b/frontend/src/hooks/api/ca/enums.tsx @@ -19,5 +19,6 @@ export enum CaRenewalType { } export enum AcmeDnsProvider { - ROUTE53 = "route53" + ROUTE53 = "route53", + Cloudflare = "cloudflare" } diff --git a/frontend/src/hooks/api/ca/types.ts b/frontend/src/hooks/api/ca/types.ts index f3703fe52..f9f812cb2 100644 --- a/frontend/src/hooks/api/ca/types.ts +++ b/frontend/src/hooks/api/ca/types.ts @@ -11,7 +11,7 @@ export type TAcmeCertificateAuthority = { configuration: { dnsAppConnectionId: string; dnsProviderConfig: { - provider: AcmeDnsProvider.ROUTE53; + provider: AcmeDnsProvider; hostedZoneId: string; }; directoryUrl: string; diff --git a/frontend/src/pages/cert-manager/CertificateAuthoritiesPage/components/ExternalCaModal.tsx b/frontend/src/pages/cert-manager/CertificateAuthoritiesPage/components/ExternalCaModal.tsx index b7d91a851..faba9ef37 100644 --- a/frontend/src/pages/cert-manager/CertificateAuthoritiesPage/components/ExternalCaModal.tsx +++ b/frontend/src/pages/cert-manager/CertificateAuthoritiesPage/components/ExternalCaModal.tsx @@ -1,5 +1,6 @@ import { useEffect } from "react"; import { Controller, useForm } from "react-hook-form"; +import { SingleValue } from "react-select"; import { zodResolver } from "@hookform/resolvers/zod"; import { z } from "zod"; @@ -16,7 +17,15 @@ import { Switch } from "@app/components/v2"; import { useWorkspace } from "@app/context"; -import { useListAvailableAppConnections } from "@app/hooks/api/appConnections"; +import { APP_CONNECTION_MAP } from "@app/helpers/appConnections"; +import { + TAvailableAppConnection, + useListAvailableAppConnections +} from "@app/hooks/api/appConnections"; +import { + TCloudflareZone, + useCloudflareConnectionListZones +} from "@app/hooks/api/appConnections/cloudflare"; import { AppConnection } from "@app/hooks/api/appConnections/enums"; import { AcmeDnsProvider, @@ -26,6 +35,10 @@ import { useGetCa, useUpdateCa } from "@app/hooks/api/ca"; +import { + ACME_DNS_PROVIDER_APP_CONNECTION_MAP, + ACME_DNS_PROVIDER_NAME_MAP +} from "@app/hooks/api/ca/constants"; import { UsePopUpState } from "@app/hooks/usePopUp"; import { slugSchema } from "@app/lib/schemas"; @@ -42,7 +55,7 @@ const schema = z id: z.string(), name: z.string() }), - // currently specific to Route53 but can be extended to others by differentiating via the provider property + // currently specific to Route53 & Cloudflare but can be extended to others by differentiating via the provider property dnsProviderConfig: z.object({ provider: z.nativeEnum(AcmeDnsProvider), hostedZoneId: z.string() @@ -105,12 +118,29 @@ export const ExternalCaModal = ({ popUp, handlePopUpToggle }: Props) => { const caType = watch("type"); const dnsProvider = watch("configuration.dnsProviderConfig.provider"); - const { data: availableConnections, isPending } = useListAvailableAppConnections( - AppConnection.AWS, - { - enabled: dnsProvider === AcmeDnsProvider.ROUTE53 - } - ); + const { data: availableRoute53Connections, isPending: isRoute53Pending } = + useListAvailableAppConnections(AppConnection.AWS, { + enabled: caType === CaType.ACME + }); + + const { data: availableCloudflareConnections, isPending: isCloudflarePending } = + useListAvailableAppConnections(AppConnection.Cloudflare, { + enabled: caType === CaType.ACME + }); + + const availableConnections: TAvailableAppConnection[] = [ + ...(availableRoute53Connections || []), + ...(availableCloudflareConnections || []) + ]; + + const isPending = isRoute53Pending || isCloudflarePending; + + const dnsAppConnection = watch("configuration.dnsAppConnection"); + + const { data: cloudflareZones = [], isPending: isZonesPending } = + useCloudflareConnectionListZones(dnsAppConnection.id, { + enabled: dnsProvider === AcmeDnsProvider.Cloudflare && !!dnsAppConnection.id + }); useEffect(() => { if (ca) { @@ -138,25 +168,6 @@ export const ExternalCaModal = ({ popUp, handlePopUpToggle }: Props) => { } }); } - } else { - reset({ - type: CaType.ACME, - name: "", - status: CaStatus.ACTIVE, - enableDirectIssuance: true, - configuration: { - dnsAppConnection: { - id: "", - name: "" - }, - dnsProviderConfig: { - provider: AcmeDnsProvider.ROUTE53, - hostedZoneId: "" - }, - directoryUrl: "", - accountEmail: "" - } - }); } }, [ca, availableConnections]); @@ -284,12 +295,11 @@ export const ExternalCaModal = ({ popUp, handlePopUpToggle }: Props) => { className="w-full" isDisabled={Boolean(ca)} > - - Route53 - + {Object.values(AcmeDnsProvider).map((provider) => ( + + {ACME_DNS_PROVIDER_NAME_MAP[provider]} + + ))} )} @@ -297,7 +307,7 @@ export const ExternalCaModal = ({ popUp, handlePopUpToggle }: Props) => { ( { control={control} name="configuration.dnsAppConnection" /> - ( - - - - )} - /> + {dnsProvider === AcmeDnsProvider.ROUTE53 && ( + ( + + + + )} + /> + )} + {dnsProvider === AcmeDnsProvider.Cloudflare && ( + ( + + zone.id === value)} + onChange={(option) => { + onChange((option as SingleValue)?.id ?? null); + }} + options={cloudflareZones} + placeholder="Select a zone..." + getOptionLabel={(option) => option.name} + getOptionValue={(option) => option.id} + /> + + )} + /> + )} {

Secret Detection

-

Define secret values to ignore when scanning designated parameter folders. Add values here to prevent false positives or allow approved sensitive data. These ignored values will not trigger policy violation alerts.

+

+ Define secret values to ignore when scanning designated parameter folders. Add values here + to prevent false positives or allow approved sensitive data. These ignored values will not + trigger policy violation alerts. +