From e7e0d84c8e19703bfddc2d8bdd4ba3eba6dedb53 Mon Sep 17 00:00:00 2001 From: Sheen Capadngan Date: Tue, 22 Oct 2024 19:41:10 +0800 Subject: [PATCH] feat: azure app configuration integration --- .../integration-auth/integration-app-list.ts | 2 + .../integration-auth/integration-list.ts | 12 +- .../integration-sync-secret.ts | 185 ++++++++++++ .../integration-auth/integration-token.ts | 34 +++ frontend/public/data/frequentConstants.ts | 3 +- .../azure-app-configuration/create.tsx | 263 ++++++++++++++++++ .../oauth2/callback.tsx | 38 +++ .../IntegrationPage.utils.tsx | 3 + 8 files changed, 538 insertions(+), 2 deletions(-) create mode 100644 frontend/src/pages/integrations/azure-app-configuration/create.tsx create mode 100644 frontend/src/pages/integrations/azure-app-configuration/oauth2/callback.tsx diff --git a/backend/src/services/integration-auth/integration-app-list.ts b/backend/src/services/integration-auth/integration-app-list.ts index 44ac825c7..fba8adfb6 100644 --- a/backend/src/services/integration-auth/integration-app-list.ts +++ b/backend/src/services/integration-auth/integration-app-list.ts @@ -1113,6 +1113,8 @@ export const getApps = async ({ }); case Integrations.AZURE_KEY_VAULT: return []; + case Integrations.AZURE_APP_CONFIGURATION: + return []; case Integrations.AWS_PARAMETER_STORE: return []; case Integrations.AWS_SECRET_MANAGER: diff --git a/backend/src/services/integration-auth/integration-list.ts b/backend/src/services/integration-auth/integration-list.ts index af9d358ed..b31f70241 100644 --- a/backend/src/services/integration-auth/integration-list.ts +++ b/backend/src/services/integration-auth/integration-list.ts @@ -33,7 +33,8 @@ export enum Integrations { NORTHFLANK = "northflank", HASURA_CLOUD = "hasura-cloud", RUNDECK = "rundeck", - AZURE_DEVOPS = "azure-devops" + AZURE_DEVOPS = "azure-devops", + AZURE_APP_CONFIGURATION = "azure-app-configuration" } export enum IntegrationType { @@ -206,6 +207,15 @@ export const getIntegrationOptions = async () => { clientId: appCfg.CLIENT_ID_AZURE, docsLink: "" }, + { + name: "Azure App Configuration", + slug: "azure-app-configuration", + image: "Microsoft Azure.png", + isAvailable: true, + type: "oauth", + clientId: appCfg.CLIENT_ID_AZURE, + docsLink: "" + }, { name: "Circle CI", slug: "circleci", diff --git a/backend/src/services/integration-auth/integration-sync-secret.ts b/backend/src/services/integration-auth/integration-sync-secret.ts index 7913b4029..bf44272c5 100644 --- a/backend/src/services/integration-auth/integration-sync-secret.ts +++ b/backend/src/services/integration-auth/integration-sync-secret.ts @@ -24,6 +24,7 @@ import { Octokit } from "@octokit/rest"; import AWS, { AWSError } from "aws-sdk"; import { AxiosError } from "axios"; import { randomUUID } from "crypto"; +import https from "https"; import sodium from "libsodium-wrappers"; import isEqual from "lodash.isequal"; import { z } from "zod"; @@ -270,6 +271,180 @@ const syncSecretsGCPSecretManager = async ({ } }; +const syncSecretsAzureAppConfig = async ({ + integration, + secrets, + accessToken, + createManySecretsRawFn, + updateManySecretsRawFn, + integrationDAL +}: { + integration: TIntegrations & { + projectId: string; + environment: { + id: string; + name: string; + slug: string; + }; + secretPath: string; + }; + secrets: Record; + accessToken: string; + createManySecretsRawFn: (params: TCreateManySecretsRawFn) => Promise>; + updateManySecretsRawFn: (params: TUpdateManySecretsRawFn) => Promise>; + integrationDAL: Pick; +}) => { + interface AzureAppConfigKeyValue { + key: string; + value: string; + } + + const getCompleteAzureAppConfigValues = async (url: string) => { + let result: AzureAppConfigKeyValue[] = []; + while (url) { + const res = await request.get(url, { + headers: { + Authorization: `Bearer ${accessToken}` + }, + // we force IPV4 because docker setup fails with ipv6 + httpsAgent: new https.Agent({ + family: 4 + }) + }); + + result = result.concat(res.data.items); + url = res.data.nextLink; + } + + return result; + }; + + const metadata = IntegrationMetadataSchema.parse(integration.metadata); + const azureAppConfigSecrets = ( + await getCompleteAzureAppConfigValues( + `${integration.app}/kv?api-version=2023-11-01&key=${metadata.secretPrefix || ""}*` + ) + ).reduce( + (accum, entry) => { + accum[entry.key] = entry.value; + + return accum; + }, + {} as Record + ); + + const secretsToAdd: { [key: string]: string } = {}; + const secretsToUpdate: { [key: string]: string } = {}; + + Object.keys(azureAppConfigSecrets).forEach((key) => { + if (!integration.lastUsed) { + // first time using integration + // -> apply initial sync behavior + switch (metadata.initialSyncBehavior) { + case IntegrationInitialSyncBehavior.OVERWRITE_TARGET: { + if (!(key in secrets)) { + secrets[key] = null; + } + break; + } + case IntegrationInitialSyncBehavior.PREFER_TARGET: { + if (!(key in secrets)) { + secretsToAdd[key] = azureAppConfigSecrets[key]; + } else if (secrets[key]?.value !== azureAppConfigSecrets[key]) { + secretsToUpdate[key] = azureAppConfigSecrets[key]; + } + secrets[key] = { + value: azureAppConfigSecrets[key] + }; + break; + } + case IntegrationInitialSyncBehavior.PREFER_SOURCE: { + if (!(key in secrets)) { + secrets[key] = { + value: azureAppConfigSecrets[key] + }; + secretsToAdd[key] = azureAppConfigSecrets[key]; + } + break; + } + default: { + break; + } + } + } else if (!(key in secrets)) { + secrets[key] = null; + } + }); + + if (Object.keys(secretsToAdd).length) { + await createManySecretsRawFn({ + projectId: integration.projectId, + environment: integration.environment.slug, + path: integration.secretPath, + secrets: Object.keys(secretsToAdd).map((key) => ({ + secretName: key, + secretValue: secretsToAdd[key], + type: SecretType.Shared, + secretComment: "" + })) + }); + } + + if (Object.keys(secretsToUpdate).length) { + await updateManySecretsRawFn({ + projectId: integration.projectId, + environment: integration.environment.slug, + path: integration.secretPath, + secrets: Object.keys(secretsToUpdate).map((key) => ({ + secretName: key, + secretValue: secretsToUpdate[key], + type: SecretType.Shared, + secretComment: "" + })) + }); + } + + // create or update secrets on Azure App Config + for await (const key of Object.keys(secrets)) { + if (!(key in azureAppConfigSecrets) || secrets[key]?.value !== azureAppConfigSecrets[key]) { + await request.put( + `${integration.app}/kv/${key}?api-version=2023-11-01`, + { + value: secrets[key]?.value + }, + { + headers: { + Authorization: `Bearer ${accessToken}` + }, + // we force IPV4 because docker setup fails with ipv6 + httpsAgent: new https.Agent({ + family: 4 + }) + } + ); + } + } + + for await (const key of Object.keys(azureAppConfigSecrets)) { + if (!(key in secrets) || secrets[key] === null) { + // case: delete secret + await request.delete(`${integration.app}/kv/${key}?api-version=2023-11-01`, { + headers: { + Authorization: `Bearer ${accessToken}` + }, + // we force IPV4 because docker setup fails with ipv6 + httpsAgent: new https.Agent({ + family: 4 + }) + }); + } + } + + await integrationDAL.updateById(integration.id, { + lastUsed: new Date() + }); +}; + /** * Sync/push [secrets] to Azure Key Vault with vault URI [integration.app] */ @@ -4041,6 +4216,16 @@ export const syncIntegrationSecrets = async ({ accessToken }); break; + case Integrations.AZURE_APP_CONFIGURATION: + await syncSecretsAzureAppConfig({ + integration, + integrationDAL, + secrets, + accessToken, + createManySecretsRawFn, + updateManySecretsRawFn + }); + break; case Integrations.AWS_PARAMETER_STORE: response = await syncSecretsAWSParameterStore({ integration, diff --git a/backend/src/services/integration-auth/integration-token.ts b/backend/src/services/integration-auth/integration-token.ts index 9b4e5c20f..362b20a07 100644 --- a/backend/src/services/integration-auth/integration-token.ts +++ b/backend/src/services/integration-auth/integration-token.ts @@ -131,6 +131,35 @@ const exchangeCodeAzure = async ({ code }: { code: string }) => { }; }; +const exchangeCodeAzureAppConfig = async ({ code }: { code: string }) => { + const accessExpiresAt = new Date(); + const appCfg = getConfig(); + if (!appCfg.CLIENT_ID_AZURE || !appCfg.CLIENT_SECRET_AZURE) { + throw new BadRequestError({ message: "Missing client id and client secret" }); + } + const res = ( + await request.post( + IntegrationUrls.AZURE_TOKEN_URL, + new URLSearchParams({ + grant_type: "authorization_code", + code, + scope: "https://azconfig.io/.default openid offline_access", + client_id: appCfg.CLIENT_ID_AZURE, + client_secret: appCfg.CLIENT_SECRET_AZURE, + redirect_uri: `${appCfg.SITE_URL}/integrations/azure-app-configuration/oauth2/callback` + }) + ) + ).data; + + accessExpiresAt.setSeconds(accessExpiresAt.getSeconds() + res.expires_in); + + return { + accessToken: res.access_token, + refreshToken: res.refresh_token, + accessExpiresAt + }; +}; + const exchangeCodeHeroku = async ({ code }: { code: string }) => { const accessExpiresAt = new Date(); const appCfg = getConfig(); @@ -434,6 +463,10 @@ export const exchangeCode = async ({ return exchangeCodeAzure({ code }); + case Integrations.AZURE_APP_CONFIGURATION: + return exchangeCodeAzureAppConfig({ + code + }); case Integrations.HEROKU: return exchangeCodeHeroku({ code @@ -746,6 +779,7 @@ export const exchangeRefresh = async ( accessExpiresAt: Date; }> => { switch (integration) { + case Integrations.AZURE_APP_CONFIGURATION: case Integrations.AZURE_KEY_VAULT: return exchangeRefreshAzure({ refreshToken diff --git a/frontend/public/data/frequentConstants.ts b/frontend/public/data/frequentConstants.ts index 80cb51028..f430cd8f4 100644 --- a/frontend/public/data/frequentConstants.ts +++ b/frontend/public/data/frequentConstants.ts @@ -35,7 +35,8 @@ const integrationSlugNameMapping: Mapping = { "gcp-secret-manager": "GCP Secret Manager", "hasura-cloud": "Hasura Cloud", rundeck: "Rundeck", - "azure-devops": "Azure DevOps" + "azure-devops": "Azure DevOps", + "azure-app-configuration": "Azure App Configuration" }; const envMapping: Mapping = { diff --git a/frontend/src/pages/integrations/azure-app-configuration/create.tsx b/frontend/src/pages/integrations/azure-app-configuration/create.tsx new file mode 100644 index 000000000..77e922ca9 --- /dev/null +++ b/frontend/src/pages/integrations/azure-app-configuration/create.tsx @@ -0,0 +1,263 @@ +import { useEffect } 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 queryString from "query-string"; +import { z } from "zod"; + +import { SecretPathInput } from "@app/components/v2/SecretPathInput"; +import { useCreateIntegration } from "@app/hooks/api"; +import { IntegrationSyncBehavior } from "@app/hooks/api/integrations/types"; + +import { + Button, + Card, + CardTitle, + FormControl, + Input, + Select, + SelectItem +} from "../../../components/v2"; +import { useGetIntegrationAuthById } from "../../../hooks/api/integrationAuth"; +import { useGetWorkspaceById } from "../../../hooks/api/workspace"; + +const schema = z.object({ + baseUrl: z + .string() + .trim() + .min(1, { message: "Azure App Configuration URL is required" }) + .url() + .refine( + (val) => val.endsWith(".azconfig.io"), + "URL should have the following format: https://resource-name-here.azconfig.io" + ), + secretPath: z.string().trim().min(1, { message: "Secret path is required" }), + sourceEnvironment: z.string().trim().min(1, { message: "Source environment is required" }), + initialSyncBehavior: z.nativeEnum(IntegrationSyncBehavior), + secretPrefix: z.string().default("") +}); + +type TFormSchema = z.infer; + +const initialSyncBehaviors = [ + { + label: "No Import - Overwrite all values in Azure App Configuration", + value: IntegrationSyncBehavior.OVERWRITE_TARGET + }, + { + label: "Import - Prefer values from Azure App Configuration", + value: IntegrationSyncBehavior.PREFER_TARGET + }, + { label: "Import - Prefer values from Infisical", value: IntegrationSyncBehavior.PREFER_SOURCE } +]; + +export default function AzureAppConfigurationCreateIntegration() { + const router = useRouter(); + const { + control, + setValue, + handleSubmit, + formState: { isSubmitting } + } = useForm({ + resolver: zodResolver(schema), + defaultValues: { + secretPath: "/", + secretPrefix: "", + initialSyncBehavior: IntegrationSyncBehavior.PREFER_SOURCE + } + }); + + const { mutateAsync } = useCreateIntegration(); + + const { integrationAuthId } = queryString.parse(router.asPath.split("?")[1]); + + const { data: workspace } = useGetWorkspaceById(localStorage.getItem("projectData.id") ?? ""); + const { data: integrationAuth } = useGetIntegrationAuthById((integrationAuthId as string) ?? ""); + + useEffect(() => { + if (workspace) { + setValue("sourceEnvironment", workspace.environments[0].slug); + } + }, [workspace]); + + const handleIntegrationSubmit = async ({ + secretPath, + sourceEnvironment, + baseUrl, + initialSyncBehavior, + secretPrefix + }: TFormSchema) => { + try { + if (!integrationAuth?.id) return; + + await mutateAsync({ + integrationAuthId: integrationAuth?.id, + isActive: true, + app: baseUrl, + sourceEnvironment, + secretPath, + metadata: { + initialSyncBehavior, + secretPrefix + } + }); + + router.push(`/integrations/${localStorage.getItem("projectData.id")}`); + } catch (err) { + console.error(err); + } + }; + + return integrationAuth && workspace ? ( +
+ + Set Up Azure App Configuration Integration + + + + +
+
+ Azure logo +
+ Azure App Configuration + + +
+ + Docs + +
+
+ +
+
+
+ ( + + + + )} + /> + ( + + + + )} + /> + ( + + + + )} + /> + ( + + + + )} + /> + ( + + + + )} + /> + +
+
+
+ ) : ( +
+ ); +} + +AzureAppConfigurationCreateIntegration.requireAuth = true; diff --git a/frontend/src/pages/integrations/azure-app-configuration/oauth2/callback.tsx b/frontend/src/pages/integrations/azure-app-configuration/oauth2/callback.tsx new file mode 100644 index 000000000..73a4fa3fc --- /dev/null +++ b/frontend/src/pages/integrations/azure-app-configuration/oauth2/callback.tsx @@ -0,0 +1,38 @@ +import { useEffect } from "react"; +import { useRouter } from "next/router"; +import queryString from "query-string"; + +import { useAuthorizeIntegration } from "@app/hooks/api"; + +export default function AzureAppConfigurationOAuth2CallbackPage() { + const router = useRouter(); + const { mutateAsync } = useAuthorizeIntegration(); + + const { code, state } = queryString.parse(router.asPath.split("?")[1]); + + useEffect(() => { + (async () => { + try { + // validate state + if (state !== localStorage.getItem("latestCSRFToken")) return; + localStorage.removeItem("latestCSRFToken"); + + const integrationAuth = await mutateAsync({ + workspaceId: localStorage.getItem("projectData.id") as string, + code: code as string, + integration: "azure-app-configuration" + }); + + router.push( + `/integrations/azure-app-configuration/create?integrationAuthId=${integrationAuth.id}` + ); + } catch (err) { + console.error(err); + } + })(); + }, []); + + return
; +} + +AzureAppConfigurationOAuth2CallbackPage.requireAuth = true; diff --git a/frontend/src/views/IntegrationsPage/IntegrationPage.utils.tsx b/frontend/src/views/IntegrationsPage/IntegrationPage.utils.tsx index be7366259..90282e1ed 100644 --- a/frontend/src/views/IntegrationsPage/IntegrationPage.utils.tsx +++ b/frontend/src/views/IntegrationsPage/IntegrationPage.utils.tsx @@ -44,6 +44,9 @@ export const redirectForProviderAuth = (integrationOption: TCloudIntegration) => case "azure-key-vault": link = `https://login.microsoftonline.com/common/oauth2/v2.0/authorize?client_id=${integrationOption.clientId}&response_type=code&redirect_uri=${window.location.origin}/integrations/azure-key-vault/oauth2/callback&response_mode=query&scope=https://vault.azure.net/.default openid offline_access&state=${state}`; break; + case "azure-app-configuration": + link = `https://login.microsoftonline.com/common/oauth2/v2.0/authorize?client_id=${integrationOption.clientId}&response_type=code&redirect_uri=${window.location.origin}/integrations/azure-app-configuration/oauth2/callback&response_mode=query&scope=https://azconfig.io/.default openid offline_access&state=${state}`; + break; case "aws-parameter-store": link = `${window.location.origin}/integrations/aws-parameter-store/authorize`; break;