diff --git a/backend/src/lib/api-docs/constants.ts b/backend/src/lib/api-docs/constants.ts index 70f5ed608c..da82016f13 100644 --- a/backend/src/lib/api-docs/constants.ts +++ b/backend/src/lib/api-docs/constants.ts @@ -661,6 +661,7 @@ export const INTEGRATION = { targetServiceId: "The service based grouping identifier ID of the external provider. Used in Terraform cloud, Checkly, Railway and NorthFlank", owner: "External integration providers service entity owner. Used in Github.", + url: "The self-hosted URL of the platform to integrate with", path: "Path to save the synced secrets. Used by Gitlab, AWS Parameter Store, Vault", region: "AWS region to sync secrets to.", scope: "Scope of the provider. Used by Github, Qovery", diff --git a/backend/src/server/routes/v1/integration-router.ts b/backend/src/server/routes/v1/integration-router.ts index f23abc45bc..bdb58aa8b3 100644 --- a/backend/src/server/routes/v1/integration-router.ts +++ b/backend/src/server/routes/v1/integration-router.ts @@ -42,6 +42,7 @@ export const registerIntegrationRouter = async (server: FastifyZodProvider) => { targetService: z.string().trim().optional().describe(INTEGRATION.CREATE.targetService), targetServiceId: z.string().trim().optional().describe(INTEGRATION.CREATE.targetServiceId), owner: z.string().trim().optional().describe(INTEGRATION.CREATE.owner), + url: z.string().trim().optional().describe(INTEGRATION.CREATE.url), path: z.string().trim().optional().describe(INTEGRATION.CREATE.path), region: z.string().trim().optional().describe(INTEGRATION.CREATE.region), scope: z.string().trim().optional().describe(INTEGRATION.CREATE.scope), diff --git a/backend/src/services/integration-auth/integration-auth-service.ts b/backend/src/services/integration-auth/integration-auth-service.ts index 74d881d266..02091d88cb 100644 --- a/backend/src/services/integration-auth/integration-auth-service.ts +++ b/backend/src/services/integration-auth/integration-auth-service.ts @@ -199,6 +199,7 @@ export const integrationAuthServiceFactory = ({ projectId, namespace, integration, + url, algorithm: SecretEncryptionAlgo.AES_256_GCM, keyEncoding: SecretKeyEncoding.UTF8, ...(integration === Integrations.GCP_SECRET_MANAGER diff --git a/backend/src/services/integration-auth/integration-list.ts b/backend/src/services/integration-auth/integration-list.ts index 2aaf5d5f41..edc426327a 100644 --- a/backend/src/services/integration-auth/integration-list.ts +++ b/backend/src/services/integration-auth/integration-list.ts @@ -30,7 +30,8 @@ export enum Integrations { DIGITAL_OCEAN_APP_PLATFORM = "digital-ocean-app-platform", CLOUD_66 = "cloud-66", NORTHFLANK = "northflank", - HASURA_CLOUD = "hasura-cloud" + HASURA_CLOUD = "hasura-cloud", + RUNDECK = "rundeck" } export enum IntegrationType { @@ -368,6 +369,15 @@ export const getIntegrationOptions = async () => { type: "pat", clientId: "", docsLink: "" + }, + { + name: "Rundeck", + slug: "rundeck", + image: "Rundeck.svg", + isAvailable: true, + type: "pat", + clientId: "", + docsLink: "" } ]; diff --git a/backend/src/services/integration-auth/integration-sync-secret.ts b/backend/src/services/integration-auth/integration-sync-secret.ts index 12c6910853..9d8ee8895d 100644 --- a/backend/src/services/integration-auth/integration-sync-secret.ts +++ b/backend/src/services/integration-auth/integration-sync-secret.ts @@ -3355,6 +3355,82 @@ const syncSecretsHasuraCloud = async ({ } }; +/** Sync/push [secrets] to Rundeck + * @param {Object} obj + * @param {TIntegrations} obj.integration - integration details + * @param {Object} obj.secrets - secrets to push to integration (object where keys are secret keys and values are secret values) + * @param {String} obj.accessToken - access token for Rundeck integration + */ +const syncSecretsRundeck = async ({ + integration, + secrets, + accessToken +}: { + integration: TIntegrations; + secrets: Record; + accessToken: string; +}) => { + interface RundeckSecretResource { + name: string; + } + interface RundeckSecretsGetRes { + resources: RundeckSecretResource[]; + } + + let existingRundeckSecrets: string[] = []; + + try { + const listResult = await request.get( + `${integration.url}/api/44/storage/${integration.path}`, + { + headers: { + "X-Rundeck-Auth-Token": accessToken + } + } + ); + + existingRundeckSecrets = listResult.data.resources.map((res) => res.name); + } catch (err) { + logger.info("No existing rundeck secrets"); + } + + try { + for await (const [key, value] of Object.entries(secrets)) { + if (existingRundeckSecrets.includes(key)) { + await request.put(`${integration.url}/api/44/storage/${integration.path}/${key}`, value.value, { + headers: { + "X-Rundeck-Auth-Token": accessToken, + "Content-Type": "application/x-rundeck-data-password" + } + }); + } else { + await request.post(`${integration.url}/api/44/storage/${integration.path}/${key}`, value.value, { + headers: { + "X-Rundeck-Auth-Token": accessToken, + "Content-Type": "application/x-rundeck-data-password" + } + }); + } + } + + for await (const existingSecret of existingRundeckSecrets) { + if (!(existingSecret in secrets)) { + await request.delete(`${integration.url}/api/44/storage/${integration.path}/${existingSecret}`, { + headers: { + "X-Rundeck-Auth-Token": accessToken + } + }); + } + } + } catch (err: unknown) { + throw new Error( + `Ensure that the provided Rundeck URL is accessible by Infisical and that the linked API token has sufficient permissions.\n\n${ + (err as Error).message + }` + ); + } +}; + /** * Sync/push [secrets] to [app] in integration named [integration] * @@ -3621,6 +3697,13 @@ export const syncIntegrationSecrets = async ({ accessToken }); break; + case Integrations.RUNDECK: + await syncSecretsRundeck({ + integration, + secrets, + accessToken + }); + break; default: throw new BadRequestError({ message: "Invalid integration" }); } diff --git a/backend/src/services/integration/integration-service.ts b/backend/src/services/integration/integration-service.ts index 821267dfb3..da9cfc71fa 100644 --- a/backend/src/services/integration/integration-service.ts +++ b/backend/src/services/integration/integration-service.ts @@ -43,6 +43,7 @@ export const integrationServiceFactory = ({ scope, actorId, region, + url, isActive, metadata, secretPath, @@ -87,6 +88,7 @@ export const integrationServiceFactory = ({ region, scope, owner, + url, appId, path, app, diff --git a/backend/src/services/integration/integration-types.ts b/backend/src/services/integration/integration-types.ts index 1c87724783..9c75cad2d5 100644 --- a/backend/src/services/integration/integration-types.ts +++ b/backend/src/services/integration/integration-types.ts @@ -12,6 +12,7 @@ export type TCreateIntegrationDTO = { targetService?: string; targetServiceId?: string; owner?: string; + url?: string; path?: string; region?: string; scope?: string; diff --git a/docs/images/integrations/rundeck/integrations-rundeck-auth.png b/docs/images/integrations/rundeck/integrations-rundeck-auth.png new file mode 100644 index 0000000000..8ffa693650 Binary files /dev/null and b/docs/images/integrations/rundeck/integrations-rundeck-auth.png differ diff --git a/docs/images/integrations/rundeck/integrations-rundeck-create.png b/docs/images/integrations/rundeck/integrations-rundeck-create.png new file mode 100644 index 0000000000..691c346f91 Binary files /dev/null and b/docs/images/integrations/rundeck/integrations-rundeck-create.png differ diff --git a/docs/images/integrations/rundeck/integrations-rundeck-token.png b/docs/images/integrations/rundeck/integrations-rundeck-token.png new file mode 100644 index 0000000000..70ae704d11 Binary files /dev/null and b/docs/images/integrations/rundeck/integrations-rundeck-token.png differ diff --git a/docs/images/integrations/rundeck/integrations-rundeck.png b/docs/images/integrations/rundeck/integrations-rundeck.png new file mode 100644 index 0000000000..170e77a3f8 Binary files /dev/null and b/docs/images/integrations/rundeck/integrations-rundeck.png differ diff --git a/docs/integrations/cicd/rundeck.mdx b/docs/integrations/cicd/rundeck.mdx new file mode 100644 index 0000000000..a0743fd01f --- /dev/null +++ b/docs/integrations/cicd/rundeck.mdx @@ -0,0 +1,39 @@ +--- +title: "Rundeck" +description: "How to sync secrets from Infisical to Rundeck" +--- + +Prerequisites: + +- Set up and add envars to [Infisical Cloud](https://app.infisical.com) + + + + Obtain a User API Token in the Profile settings of Rundeck + + ![integrations rundeck token](../../images/integrations/rundeck/integrations-rundeck-token.png) + + Navigate to your project's integrations tab in Infisical. + + ![integrations](../../images/integrations.png) + + Press on the Rundeck tile and input your Rundeck instance Base URL and User API token to grant Infisical access to manage Rundeck keys + + ![integrations rundeck authorization](../../images/integrations/rundeck/integrations-rundeck-auth.png) + + + If this is your project's first cloud integration, then you'll have to grant + Infisical access to your project's environment variables. Although this step + breaks E2EE, it's necessary for Infisical to sync the environment variables to + the cloud platform. + + + + + Select which Infisical environment secrets you want to sync to a Rundeck Key Storage Path and press create integration to start syncing secrets to Rundeck. + + ![create integration rundeck](../../images/integrations/rundeck/integrations-rundeck-create.png) + ![integrations rundeck](../../images/integrations/rundeck/integrations-rundeck.png) + + + diff --git a/docs/integrations/overview.mdx b/docs/integrations/overview.mdx index 784f934eee..b29db8420c 100644 --- a/docs/integrations/overview.mdx +++ b/docs/integrations/overview.mdx @@ -26,14 +26,14 @@ Missing an integration? [Throw in a request](https://github.com/Infisical/infisi | [Supabase](/integrations/cloud/supabase) | Cloud | Available | | [Northflank](/integrations/cloud/northflank) | Cloud | Available | | [Cloudflare Pages](/integrations/cloud/cloudflare-pages) | Cloud | Available | -| [Cloudflare Workers](/integrations/cloud/cloudflare-workers) | Cloud | Available | +| [Cloudflare Workers](/integrations/cloud/cloudflare-workers) | Cloud | Available | | [Checkly](/integrations/cloud/checkly) | Cloud | Available | -| [Qovery](/integrations/cloud/qovery) | Cloud | Available | +| [Qovery](/integrations/cloud/qovery) | Cloud | Available | | [HashiCorp Vault](/integrations/cloud/hashicorp-vault) | Cloud | Available | | [AWS Parameter Store](/integrations/cloud/aws-parameter-store) | Cloud | Available | -| [AWS Secrets Manager](/integrations/cloud/aws-secret-manager) | Cloud | Available | +| [AWS Secrets Manager](/integrations/cloud/aws-secret-manager) | Cloud | Available | | [Azure Key Vault](/integrations/cloud/azure-key-vault) | Cloud | Available | -| [GCP Secret Manager](/integrations/cloud/gcp-secret-manager) | Cloud | Available | +| [GCP Secret Manager](/integrations/cloud/gcp-secret-manager) | Cloud | Available | | [Windmill](/integrations/cloud/windmill) | Cloud | Available | | [BitBucket](/integrations/cicd/bitbucket) | CI/CD | Available | | [Codefresh](/integrations/cicd/codefresh) | CI/CD | Available | @@ -41,6 +41,7 @@ Missing an integration? [Throw in a request](https://github.com/Infisical/infisi | [GitLab](/integrations/cicd/gitlab) | CI/CD | Available | | [CircleCI](/integrations/cicd/circleci) | CI/CD | Available | | [Travis CI](/integrations/cicd/travisci) | CI/CD | Available | +| [Rundeck](/integrations/cicd/rundeck) | CI/CD | Available | | [React](/integrations/frameworks/react) | Framework | Available | | [Vue](/integrations/frameworks/vue) | Framework | Available | | [Express](/integrations/frameworks/express) | Framework | Available | diff --git a/docs/mint.json b/docs/mint.json index 610f362c26..4dcc3aaa55 100644 --- a/docs/mint.json +++ b/docs/mint.json @@ -344,6 +344,7 @@ "pages": [ "integrations/cicd/circleci", "integrations/cicd/travisci", + "integrations/cicd/rundeck", "integrations/cicd/codefresh", "integrations/cloud/checkly" ] diff --git a/frontend/public/data/frequentConstants.ts b/frontend/public/data/frequentConstants.ts index 451890ef98..cf90ef659f 100644 --- a/frontend/public/data/frequentConstants.ts +++ b/frontend/public/data/frequentConstants.ts @@ -32,7 +32,8 @@ const integrationSlugNameMapping: Mapping = { northflank: "Northflank", windmill: "Windmill", "gcp-secret-manager": "GCP Secret Manager", - "hasura-cloud": "Hasura Cloud" + "hasura-cloud": "Hasura Cloud", + rundeck: "Rundeck" }; const envMapping: Mapping = { diff --git a/frontend/public/images/integrations/Rundeck.svg b/frontend/public/images/integrations/Rundeck.svg new file mode 100644 index 0000000000..4ded97a8a3 --- /dev/null +++ b/frontend/public/images/integrations/Rundeck.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/hooks/api/integrationAuth/types.ts b/frontend/src/hooks/api/integrationAuth/types.ts index 4a4c5e2812..b73528384c 100644 --- a/frontend/src/hooks/api/integrationAuth/types.ts +++ b/frontend/src/hooks/api/integrationAuth/types.ts @@ -7,6 +7,7 @@ export type IntegrationAuth = { updatedAt: string; algorithm: string; keyEncoding: string; + url?: string; teamId?: string; }; diff --git a/frontend/src/hooks/api/integrations/queries.tsx b/frontend/src/hooks/api/integrations/queries.tsx index 7325dc4a3e..3aa8f3ed1d 100644 --- a/frontend/src/hooks/api/integrations/queries.tsx +++ b/frontend/src/hooks/api/integrations/queries.tsx @@ -41,6 +41,7 @@ export const useCreateIntegration = () => { owner, path, region, + url, scope, secretPath, metadata @@ -56,6 +57,7 @@ export const useCreateIntegration = () => { targetService?: string; targetServiceId?: string; owner?: string; + url?: string; path?: string; region?: string; scope?: string; @@ -85,6 +87,7 @@ export const useCreateIntegration = () => { targetEnvironmentId, targetService, targetServiceId, + url, owner, path, scope, diff --git a/frontend/src/pages/integrations/rundeck/authorize.tsx b/frontend/src/pages/integrations/rundeck/authorize.tsx new file mode 100644 index 0000000000..5790f573b6 --- /dev/null +++ b/frontend/src/pages/integrations/rundeck/authorize.tsx @@ -0,0 +1,129 @@ +import { useState } from "react"; +import { Controller, useForm } from "react-hook-form"; +import Head from "next/head"; +import Image from "next/image"; +import Link from "next/link"; +import { useRouter } from "next/router"; +import { faArrowUpRightFromSquare, faBookOpen } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { zodResolver } from "@hookform/resolvers/zod"; +import z from "zod"; + +import { Button, Card, CardTitle, FormControl, Input } from "@app/components/v2"; +import { useSaveIntegrationAccessToken } from "@app/hooks/api"; + +const schema = z.object({ + authToken: z.string().trim().min(1, { message: "Rundeck Auth Token is required" }), + rundeckURL: z.string().trim().min(1, { + message: "Rundeck URL is required" + }) +}); + +type FormData = z.infer; + +export default function RundeckAuthorizeIntegrationPage() { + const router = useRouter(); + const [isLoading, setIsLoading] = useState(false); + const { mutateAsync } = useSaveIntegrationAccessToken(); + + const { control, handleSubmit } = useForm({ + resolver: zodResolver(schema), + defaultValues: { + authToken: "", + rundeckURL: "" + } + }); + + const onFormSubmit = async ({ authToken, rundeckURL }: FormData) => { + try { + setIsLoading(true); + + const integrationAuth = await mutateAsync({ + workspaceId: localStorage.getItem("projectData.id"), + integration: "rundeck", + accessToken: authToken, + url: rundeckURL.trim() + }); + + setIsLoading(false); + router.push(`/integrations/rundeck/create?integrationAuthId=${integrationAuth.id}`); + } catch (err) { + setIsLoading(false); + console.error(err); + } + }; + return ( +
+ + Authorize Rundeck Integration + + + + +
+
+ Rundeck logo +
+ Rundeck Integration + + +
+ + Docs + +
+
+ +
+
+
+ ( + + + + )} + /> + ( + + + + )} + /> + + +
+
+ ); +} + +RundeckAuthorizeIntegrationPage.requireAuth = true; diff --git a/frontend/src/pages/integrations/rundeck/create.tsx b/frontend/src/pages/integrations/rundeck/create.tsx new file mode 100644 index 0000000000..543d9f4b0d --- /dev/null +++ b/frontend/src/pages/integrations/rundeck/create.tsx @@ -0,0 +1,217 @@ +import { Controller, useForm } from "react-hook-form"; +import Head from "next/head"; +import Image from "next/image"; +import Link from "next/link"; +import { useRouter } from "next/router"; +import { faArrowUpRightFromSquare, faBookOpen, faBugs } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { zodResolver } from "@hookform/resolvers/zod"; +import queryString from "query-string"; +import { z } from "zod"; + +import { + Button, + Card, + CardTitle, + FormControl, + Input, + Select, + SelectItem +} from "@app/components/v2"; +import { SecretPathInput } from "@app/components/v2/SecretPathInput"; +import { useCreateIntegration } from "@app/hooks/api"; +import { useGetIntegrationAuthById } from "@app/hooks/api/integrationAuth"; +import { useGetWorkspaceById } from "@app/hooks/api/workspace"; + +const schema = z.object({ + keyStoragePath: z.string().trim().min(1, { message: "Rundeck Key Storage path is required" }), + secretPath: z.string().trim().min(1, { message: "Secret path is required" }), + sourceEnvironment: z.string().trim().min(1, { message: "Source environment is required" }) +}); + +type TFormSchema = z.infer; + +export default function RundeckCreateIntegrationPage() { + const { + control, + handleSubmit, + watch, + formState: { isSubmitting } + } = useForm({ + resolver: zodResolver(schema), + defaultValues: { + secretPath: "/" + } + }); + const router = useRouter(); + const { mutateAsync } = useCreateIntegration(); + const { integrationAuthId } = queryString.parse(router.asPath.split("?")[1]); + + const { data: workspace } = useGetWorkspaceById(localStorage.getItem("projectData.id") ?? ""); + const { data: integrationAuth, isLoading: isIntegrationAuthLoading } = useGetIntegrationAuthById( + (integrationAuthId as string) ?? "" + ); + + const selectedSourceEnvironment = watch("sourceEnvironment"); + + const onFormSubmit = async ({ secretPath, sourceEnvironment, keyStoragePath }: TFormSchema) => { + try { + if (!integrationAuth?.id) return; + + await mutateAsync({ + integrationAuthId: integrationAuth?.id, + isActive: true, + path: keyStoragePath, + sourceEnvironment, + url: integrationAuth.url, + secretPath + }); + + router.push(`/integrations/${localStorage.getItem("projectData.id")}`); + } catch (err) { + console.error(err); + } + }; + + return integrationAuth && workspace ? ( +
+ + Set Up Rundeck Integration + + + + +
+
+ Rundeck logo +
+ Rundeck Integration + + +
+ + Docs + +
+
+ +
+
+ +
+ ( + + + + )} + /> + + ( + + + + )} + /> + + ( + + + + )} + /> + + + +
+
+ ) : ( +
+ + Set Up Rundeck Integration + + + {isIntegrationAuthLoading ? ( + infisical loading indicator + ) : ( +
+ +

+ Something went wrong. Please contact{" "} + + support@infisical.com + {" "} + if the issue persists. +

+
+ )} +
+ ); +} + +RundeckCreateIntegrationPage.requireAuth = true; diff --git a/frontend/src/views/IntegrationsPage/IntegrationPage.utils.tsx b/frontend/src/views/IntegrationsPage/IntegrationPage.utils.tsx index 10ef21e278..1aee4c6568 100644 --- a/frontend/src/views/IntegrationsPage/IntegrationPage.utils.tsx +++ b/frontend/src/views/IntegrationsPage/IntegrationPage.utils.tsx @@ -128,6 +128,9 @@ export const redirectForProviderAuth = (integrationOption: TCloudIntegration) => case "hasura-cloud": link = `${window.location.origin}/integrations/hasura-cloud/authorize`; break; + case "rundeck": + link = `${window.location.origin}/integrations/rundeck/authorize`; + break; default: break; } diff --git a/frontend/src/views/IntegrationsPage/components/IntegrationsSection/IntegrationsSection.tsx b/frontend/src/views/IntegrationsPage/components/IntegrationsSection/IntegrationsSection.tsx index 267ff85800..d560102783 100644 --- a/frontend/src/views/IntegrationsPage/components/IntegrationsSection/IntegrationsSection.tsx +++ b/frontend/src/views/IntegrationsPage/components/IntegrationsSection/IntegrationsSection.tsx @@ -141,7 +141,8 @@ export const IntegrationsSection = ({ label={ (integration.integration === "qovery" && integration?.scope) || (integration.integration === "aws-secret-manager" && "Secret") || - (integration.integration === "aws-parameter-store" && "Path") || + (["aws-parameter-store", "rundeck"].includes(integration.integration) && + "Path") || (integration?.integration === "terraform-cloud" && "Project") || (integration?.scope === "github-org" && "Organization") || (["github-repo", "github-env"].includes(integration?.scope as string) && @@ -153,7 +154,7 @@ export const IntegrationsSection = ({ {(integration.integration === "hashicorp-vault" && `${integration.app} - path: ${integration.path}`) || (integration.scope === "github-org" && `${integration.owner}`) || - (integration.integration === "aws-parameter-store" && + (["aws-parameter-store", "rundeck"].includes(integration.integration) && `${integration.path}`) || (integration.scope?.startsWith("github-") && `${integration.owner}/${integration.app}`) ||