From 326764dd410b4a9fb7312e481e9d6bf50b103e0e Mon Sep 17 00:00:00 2001 From: Daniel Hougaard Date: Tue, 20 Aug 2024 17:13:41 +0400 Subject: [PATCH] Feat: Azure DevOps Integration --- .../dynamic-secret/dynamic-secret-service.ts | 18 +++ .../src/ee/services/license/licence-fns.ts | 2 +- .../integration-auth/integration-app-list.ts | 26 +++ .../integration-auth-types.ts | 11 ++ .../integration-auth/integration-list.ts | 13 +- .../integration-sync-secret.ts | 138 ++++++++++++++-- frontend/public/data/frequentConstants.ts | 3 +- .../integrations/azure-devops/authorize.tsx | 76 +++++++++ .../integrations/azure-devops/create.tsx | 150 ++++++++++++++++++ .../IntegrationPage.utils.tsx | 3 + 10 files changed, 428 insertions(+), 12 deletions(-) create mode 100644 frontend/src/pages/integrations/azure-devops/authorize.tsx create mode 100644 frontend/src/pages/integrations/azure-devops/create.tsx diff --git a/backend/src/ee/services/dynamic-secret/dynamic-secret-service.ts b/backend/src/ee/services/dynamic-secret/dynamic-secret-service.ts index 1aef3cc86..e560c837d 100644 --- a/backend/src/ee/services/dynamic-secret/dynamic-secret-service.ts +++ b/backend/src/ee/services/dynamic-secret/dynamic-secret-service.ts @@ -98,6 +98,23 @@ export const dynamicSecretServiceFactory = ({ if (!isConnected) throw new BadRequestError({ message: "Provider connection failed" }); const encryptedInput = infisicalSymmetricEncypt(JSON.stringify(inputs)); + + console.log("YES THIS IS WHERE WE ARE"); + + console.log({ + type: provider.type, + version: 1, + inputIV: encryptedInput.iv, + inputTag: encryptedInput.tag, + inputCiphertext: encryptedInput.ciphertext, + algorithm: encryptedInput.algorithm, + keyEncoding: encryptedInput.encoding, + maxTTL, + defaultTTL, + folderId: folder.id, + name + }); + const dynamicSecretCfg = await dynamicSecretDAL.create({ type: provider.type, version: 1, @@ -111,6 +128,7 @@ export const dynamicSecretServiceFactory = ({ folderId: folder.id, name }); + console.log("IT WORKED"); return dynamicSecretCfg; }; diff --git a/backend/src/ee/services/license/licence-fns.ts b/backend/src/ee/services/license/licence-fns.ts index bd40f75cb..b7dc3d694 100644 --- a/backend/src/ee/services/license/licence-fns.ts +++ b/backend/src/ee/services/license/licence-fns.ts @@ -17,7 +17,7 @@ export const getDefaultOnPremFeatures = (): TFeatureSet => ({ environmentsUsed: 0, identityLimit: null, identitiesUsed: 0, - dynamicSecret: false, + dynamicSecret: true, secretVersioning: true, pitRecovery: false, ipAllowlisting: false, diff --git a/backend/src/services/integration-auth/integration-app-list.ts b/backend/src/services/integration-auth/integration-app-list.ts index 9cb0d822c..4fd8264f5 100644 --- a/backend/src/services/integration-auth/integration-app-list.ts +++ b/backend/src/services/integration-auth/integration-app-list.ts @@ -1030,6 +1030,26 @@ const getAppsCloud66 = async ({ accessToken }: { accessToken: string }) => { return apps; }; +const getAppsAzureDevOps = async ({ accessToken, orgId }: { accessToken: string; orgId: string }) => { + console.log({ accessToken, orgId }); + const res = ( + await request.get<{ count: number; value: Record[] }>( + `${IntegrationUrls.AZURE_DEVOPS_API_URL}/${orgId}/_apis/projects?api-version=7.2-preview.2`, + { + headers: { + Authorization: `Basic ${accessToken}` + } + } + ) + ).data; + const apps = res.value.map((a) => ({ + name: a.name, + appId: a.id + })); + + return apps; +}; + export const getApps = async ({ integration, accessToken, @@ -1184,6 +1204,12 @@ export const getApps = async ({ accessToken }); + case Integrations.AZURE_DEVOPS: + return getAppsAzureDevOps({ + accessToken, + orgId: teamId as string // small hack to pass orgId as teamId + }); + default: throw new BadRequestError({ message: "integration not found" }); } diff --git a/backend/src/services/integration-auth/integration-auth-types.ts b/backend/src/services/integration-auth/integration-auth-types.ts index 5d1bfc18f..44b6d4c4c 100644 --- a/backend/src/services/integration-auth/integration-auth-types.ts +++ b/backend/src/services/integration-auth/integration-auth-types.ts @@ -1,3 +1,4 @@ +import { TIntegrations } from "@app/db/schemas"; import { TProjectPermission } from "@app/lib/types"; export type TGetIntegrationAuthDTO = { @@ -163,3 +164,13 @@ export type TTeamCityBuildConfig = { href: string; webUrl: string; }; + +export type TIntegrationsWithEnvironment = TIntegrations & { + environment?: + | { + id?: string | null | undefined; + name?: string | null | undefined; + } + | null + | undefined; +}; diff --git a/backend/src/services/integration-auth/integration-list.ts b/backend/src/services/integration-auth/integration-list.ts index edc426327..46c76c5cf 100644 --- a/backend/src/services/integration-auth/integration-list.ts +++ b/backend/src/services/integration-auth/integration-list.ts @@ -31,7 +31,8 @@ export enum Integrations { CLOUD_66 = "cloud-66", NORTHFLANK = "northflank", HASURA_CLOUD = "hasura-cloud", - RUNDECK = "rundeck" + RUNDECK = "rundeck", + AZURE_DEVOPS = "azure-devops" } export enum IntegrationType { @@ -88,6 +89,7 @@ export enum IntegrationUrls { CLOUD_66_API_URL = "https://app.cloud66.com/api", NORTHFLANK_API_URL = "https://api.northflank.com", HASURA_CLOUD_API_URL = "https://data.pro.hasura.io/v1/graphql", + AZURE_DEVOPS_API_URL = "https://dev.azure.com", GCP_SECRET_MANAGER_SERVICE_NAME = "secretmanager.googleapis.com", GCP_SECRET_MANAGER_URL = `https://${GCP_SECRET_MANAGER_SERVICE_NAME}`, @@ -378,6 +380,15 @@ export const getIntegrationOptions = async () => { type: "pat", clientId: "", docsLink: "" + }, + { + name: "Azure Devops", + slug: "azure-devops", + image: "Microsoft Azure.png", + 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 b1f7d4cb8..ba22178d5 100644 --- a/backend/src/services/integration-auth/integration-sync-secret.ts +++ b/backend/src/services/integration-auth/integration-sync-secret.ts @@ -26,7 +26,7 @@ import sodium from "libsodium-wrappers"; import isEqual from "lodash.isequal"; import { z } from "zod"; -import { SecretType, TIntegrationAuths, TIntegrations } from "@app/db/schemas"; +import { SecretType, TIntegrationAuths, TIntegrations, TSecrets } from "@app/db/schemas"; import { getConfig } from "@app/lib/config/env"; import { request } from "@app/lib/config/request"; import { BadRequestError } from "@app/lib/errors"; @@ -35,6 +35,7 @@ import { TCreateManySecretsRawFn, TUpdateManySecretsRawFn } from "@app/services/ import { TIntegrationDALFactory } from "../integration/integration-dal"; import { IntegrationMetadataSchema } from "../integration/integration-schema"; +import { TIntegrationsWithEnvironment } from "./integration-auth-types"; import { IntegrationInitialSyncBehavior, IntegrationMappingBehavior, @@ -275,8 +276,8 @@ const syncSecretsAzureKeyVault = async ({ }; secrets: Record; accessToken: string; - createManySecretsRawFn: (params: TCreateManySecretsRawFn) => Promise>; - updateManySecretsRawFn: (params: TUpdateManySecretsRawFn) => Promise>; + createManySecretsRawFn: (params: TCreateManySecretsRawFn) => Promise>; + updateManySecretsRawFn: (params: TUpdateManySecretsRawFn) => Promise>; }) => { interface GetAzureKeyVaultSecret { id: string; // secret URI @@ -966,8 +967,8 @@ const syncSecretsHeroku = async ({ secrets, accessToken }: { - createManySecretsRawFn: (params: TCreateManySecretsRawFn) => Promise>; - updateManySecretsRawFn: (params: TUpdateManySecretsRawFn) => Promise>; + createManySecretsRawFn: (params: TCreateManySecretsRawFn) => Promise>; + updateManySecretsRawFn: (params: TUpdateManySecretsRawFn) => Promise>; integration: TIntegrations & { projectId: string; environment: { @@ -2075,6 +2076,116 @@ const syncSecretsTravisCI = async ({ } }; +/** + * Sync/push [secrets] to GitLab repo with name [integration.app] + */ +const syncSecretsAzureDevops = async ({ + integrationAuth, + integration, + secrets, + accessToken +}: { + integrationAuth: TIntegrationAuths; + integration: TIntegrationsWithEnvironment; + secrets: Record; + accessToken: string; +}) => { + if (!integration.appId || !integration.app) { + throw new Error("Azure DevOps: orgId and projectId are required"); + } + if (!integration.environment || !integration.environment.name) { + throw new Error("Azure DevOps: environment is required"); + } + const headers = { + Authorization: `Basic ${accessToken}` + }; + const azureDevopsApiUrl = integrationAuth.url ? `${integrationAuth.url}` : IntegrationUrls.AZURE_DEVOPS_API_URL; + + const getEnvGroupId = async (orgId: string, project: string, env: string) => { + let groupId; + const url: string | null = + `${azureDevopsApiUrl}/${orgId}/${project}/_apis/distributedtask/variablegroups?api-version=7.2-preview.2`; + + const response = await request.get(url, { headers }); + for (const group of response.data.value) { + const groupName = group.name; + if (groupName === env) { + groupId = group.id; + return { groupId, groupName }; + } + } + return { groupId: "", groupName: "" }; + }; + + const { groupId, groupName } = await getEnvGroupId(integration.app, integration.appId, integration.environment.name); + + const variables: Record = {}; + for await (const key of Object.keys(secrets)) { + variables[key] = { value: secrets[key].value }; + } + + if (!groupId) { + // create new variable group if not present + const url = `${azureDevopsApiUrl}/${integration.app}/_apis/distributedtask/variablegroups?api-version=7.2-preview.2`; + const config = { + method: "POST", + url, + data: { + name: integration.environment.name, + description: integration.environment.name, + type: "Vsts", + owner: "Library", + variables, + variableGroupProjectReferences: [ + { + name: integration.environment.name, + projectReference: { + name: integration.appId + } + } + ] + }, + headers: { + headers + } + }; + + const res = await request.post(url, config.data, config.headers); + if (res.status !== 200) { + throw new Error(`Azure DevOps: Failed to create variable group: ${res.statusText}`); + } + } else { + // sync variables for pre-existing variable group + const url = `${azureDevopsApiUrl}/${integration.app}/_apis/distributedtask/variablegroups/${groupId}?api-version=7.2-preview.2`; + const config = { + method: "PUT", + url, + data: { + name: groupName, + description: groupName, + type: "Vsts", + owner: "Library", + variables, + variableGroupProjectReferences: [ + { + name: groupName, + projectReference: { + name: integration.appId + } + } + ] + }, + headers: { + headers + } + }; + const res = await request.put(url, config.data, config.headers); + if (res.status !== 200) { + throw new Error(`Azure DevOps: Failed to update variable group: ${res.statusText}`); + } + } +}; + /** * Sync/push [secrets] to GitLab repo with name [integration.app] */ @@ -2527,8 +2638,8 @@ const syncSecretsTerraformCloud = async ({ accessToken, integrationDAL }: { - createManySecretsRawFn: (params: TCreateManySecretsRawFn) => Promise>; - updateManySecretsRawFn: (params: TUpdateManySecretsRawFn) => Promise>; + createManySecretsRawFn: (params: TCreateManySecretsRawFn) => Promise>; + updateManySecretsRawFn: (params: TUpdateManySecretsRawFn) => Promise>; integration: TIntegrations & { projectId: string; environment: { @@ -3675,8 +3786,8 @@ export const syncIntegrationSecrets = async ({ appendices, projectId }: { - createManySecretsRawFn: (params: TCreateManySecretsRawFn) => Promise>; - updateManySecretsRawFn: (params: TUpdateManySecretsRawFn) => Promise>; + createManySecretsRawFn: (params: TCreateManySecretsRawFn) => Promise>; + updateManySecretsRawFn: (params: TUpdateManySecretsRawFn) => Promise>; integrationDAL: Pick; integration: TIntegrations & { projectId: string; @@ -3714,6 +3825,15 @@ export const syncIntegrationSecrets = async ({ updateManySecretsRawFn }); break; + + case Integrations.AZURE_DEVOPS: + await syncSecretsAzureDevops({ + integrationAuth, + integration, + secrets, + accessToken + }); + break; case Integrations.AWS_PARAMETER_STORE: response = await syncSecretsAWSParameterStore({ integration, diff --git a/frontend/public/data/frequentConstants.ts b/frontend/public/data/frequentConstants.ts index cf90ef659..1fd7e7789 100644 --- a/frontend/public/data/frequentConstants.ts +++ b/frontend/public/data/frequentConstants.ts @@ -33,7 +33,8 @@ const integrationSlugNameMapping: Mapping = { windmill: "Windmill", "gcp-secret-manager": "GCP Secret Manager", "hasura-cloud": "Hasura Cloud", - rundeck: "Rundeck" + rundeck: "Rundeck", + "azure-devops": "Azure DevOps" }; const envMapping: Mapping = { diff --git a/frontend/src/pages/integrations/azure-devops/authorize.tsx b/frontend/src/pages/integrations/azure-devops/authorize.tsx new file mode 100644 index 000000000..8dbb2e946 --- /dev/null +++ b/frontend/src/pages/integrations/azure-devops/authorize.tsx @@ -0,0 +1,76 @@ +import { useState } from "react"; +import { useRouter } from "next/router"; + +import { useSaveIntegrationAccessToken } from "@app/hooks/api"; + +import { Button, Card, CardTitle, FormControl, Input } from "../../../components/v2"; + +export default function AzureDevopsCreateIntegrationPage() { + const router = useRouter(); + const { mutateAsync } = useSaveIntegrationAccessToken(); + + const [apiKey, setApiKey] = useState(""); + const [orgId, setOrgId] = useState(""); + const [apiKeyErrorText, setApiKeyErrorText] = useState(""); + const [isLoading, setIsLoading] = useState(false); + + const handleButtonClick = async () => { + try { + setApiKeyErrorText(""); + if (apiKey.length === 0) { + setApiKeyErrorText("API Key cannot be blank"); + return; + } + + setIsLoading(true); + + localStorage.setItem("azure-devops-org-id", orgId); + + const integrationAuth = await mutateAsync({ + workspaceId: localStorage.getItem("projectData.id"), + integration: "azure-devops", + accessToken: btoa(`:${apiKey}`) // This is a base64 encoding of the API key without any username + }); + + setIsLoading(false); + + router.push(`/integrations/azure-devops/create?integrationAuthId=${integrationAuth.id}`); + } catch (err) { + console.error(err); + } + }; + + return ( +
+ + AzureDevops Integration + + setApiKey(e.target.value)} /> + + + setOrgId(e.target.value)} /> + + + + +
+ ); +} + +AzureDevopsCreateIntegrationPage.requireAuth = true; diff --git a/frontend/src/pages/integrations/azure-devops/create.tsx b/frontend/src/pages/integrations/azure-devops/create.tsx new file mode 100644 index 000000000..59b9720d8 --- /dev/null +++ b/frontend/src/pages/integrations/azure-devops/create.tsx @@ -0,0 +1,150 @@ +import { useEffect, useState } from "react"; +import { useRouter } from "next/router"; +import queryString from "query-string"; + +import { useCreateIntegration } from "@app/hooks/api"; + +import { + Button, + Card, + CardTitle, + FormControl, + Input, + Select, + SelectItem +} from "../../../components/v2"; +import { + useGetIntegrationAuthApps, + useGetIntegrationAuthById +} from "../../../hooks/api/integrationAuth"; +import { useGetWorkspaceById } from "../../../hooks/api/workspace"; + +export default function AzureDevopsCreateIntegrationPage() { + 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 } = useGetIntegrationAuthById((integrationAuthId as string) ?? ""); + const { data: integrationAuthApps } = useGetIntegrationAuthApps({ + integrationAuthId: (integrationAuthId as string) ?? "", + teamId: localStorage.getItem("azure-devops-org-id") ?? "" + }); + + const [selectedSourceEnvironment, setSelectedSourceEnvironment] = useState(""); + const [secretPath, setSecretPath] = useState("/"); + const [targetApp, setTargetApp] = useState(""); + + const [isLoading, setIsLoading] = useState(false); + + useEffect(() => { + if (workspace) { + setSelectedSourceEnvironment(workspace.environments[0].slug); + } + }, [workspace]); + + useEffect(() => { + if (integrationAuthApps) { + if (integrationAuthApps.length > 0) { + setTargetApp(integrationAuthApps[0].name); + } else { + setTargetApp("none"); + } + } + }, [integrationAuthApps]); + + const handleButtonClick = async () => { + try { + if (!integrationAuth?.id) return; + + setIsLoading(true); + + await mutateAsync({ + integrationAuthId: integrationAuth?.id, + isActive: true, + app: localStorage.getItem("azure-devops-org-id") || "", + appId: targetApp, + sourceEnvironment: selectedSourceEnvironment, + secretPath + }); + + setIsLoading(false); + + router.push(`/integrations/${localStorage.getItem("projectData.id")}`); + } catch (err) { + console.error(err); + } + }; + + return integrationAuth && + workspace && + selectedSourceEnvironment && + integrationAuthApps && + targetApp ? ( +
+ + AzureDevops Integration + + + + + setSecretPath(evt.target.value)} + placeholder="Provide a path, default is /" + /> + + + + + + +
+ ) : ( +
+ ); +} + +AzureDevopsCreateIntegrationPage.requireAuth = true; diff --git a/frontend/src/views/IntegrationsPage/IntegrationPage.utils.tsx b/frontend/src/views/IntegrationsPage/IntegrationPage.utils.tsx index 1aee4c656..69d603f20 100644 --- a/frontend/src/views/IntegrationsPage/IntegrationPage.utils.tsx +++ b/frontend/src/views/IntegrationsPage/IntegrationPage.utils.tsx @@ -131,6 +131,9 @@ export const redirectForProviderAuth = (integrationOption: TCloudIntegration) => case "rundeck": link = `${window.location.origin}/integrations/rundeck/authorize`; break; + case "azure-devops": + link = `${window.location.origin}/integrations/azure-devops/authorize`; + break; default: break; }