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..ea08b212a 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,7 @@ export const dynamicSecretServiceFactory = ({ if (!isConnected) throw new BadRequestError({ message: "Provider connection failed" }); const encryptedInput = infisicalSymmetricEncypt(JSON.stringify(inputs)); + const dynamicSecretCfg = await dynamicSecretDAL.create({ type: provider.type, version: 1, diff --git a/backend/src/server/routes/v1/integration-auth-router.ts b/backend/src/server/routes/v1/integration-auth-router.ts index 963b7101c..4baa39f76 100644 --- a/backend/src/server/routes/v1/integration-auth-router.ts +++ b/backend/src/server/routes/v1/integration-auth-router.ts @@ -293,6 +293,7 @@ export const registerIntegrationAuthRouter = async (server: FastifyZodProvider) }), querystring: z.object({ teamId: z.string().trim().optional(), + azureDevOpsOrgName: z.string().trim().optional(), workspaceSlug: z.string().trim().optional() }), response: { diff --git a/backend/src/services/integration-auth/integration-app-list.ts b/backend/src/services/integration-auth/integration-app-list.ts index 9cb0d822c..bd7c19228 100644 --- a/backend/src/services/integration-auth/integration-app-list.ts +++ b/backend/src/services/integration-auth/integration-app-list.ts @@ -1030,11 +1030,31 @@ const getAppsCloud66 = async ({ accessToken }: { accessToken: string }) => { return apps; }; +const getAppsAzureDevOps = async ({ accessToken, orgName }: { accessToken: string; orgName: string }) => { + const res = ( + await request.get<{ count: number; value: Record[] }>( + `${IntegrationUrls.AZURE_DEVOPS_API_URL}/${orgName}/_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, accessId, teamId, + azureDevOpsOrgName, workspaceSlug, url }: { @@ -1042,6 +1062,7 @@ export const getApps = async ({ accessToken: string; accessId?: string; teamId?: string | null; + azureDevOpsOrgName?: string | null; workspaceSlug?: string; url?: string | null; }): Promise => { @@ -1184,6 +1205,12 @@ export const getApps = async ({ accessToken }); + case Integrations.AZURE_DEVOPS: + return getAppsAzureDevOps({ + accessToken, + orgName: azureDevOpsOrgName as string + }); + default: throw new BadRequestError({ message: "integration not found" }); } diff --git a/backend/src/services/integration-auth/integration-auth-service.ts b/backend/src/services/integration-auth/integration-auth-service.ts index e16f6bb77..7b201e6ea 100644 --- a/backend/src/services/integration-auth/integration-auth-service.ts +++ b/backend/src/services/integration-auth/integration-auth-service.ts @@ -440,6 +440,7 @@ export const integrationAuthServiceFactory = ({ actorOrgId, actorAuthMethod, teamId, + azureDevOpsOrgName, id, workspaceSlug }: TIntegrationAuthAppsDTO) => { @@ -462,6 +463,7 @@ export const integrationAuthServiceFactory = ({ accessToken, accessId, teamId, + azureDevOpsOrgName, workspaceSlug, url: integrationAuth.url }); diff --git a/backend/src/services/integration-auth/integration-auth-types.ts b/backend/src/services/integration-auth/integration-auth-types.ts index 5d1bfc18f..af390297a 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 = { @@ -28,6 +29,7 @@ export type TDeleteIntegrationAuthsDTO = TProjectPermission & { export type TIntegrationAuthAppsDTO = { id: string; teamId?: string; + azureDevOpsOrgName?: string; workspaceSlug?: string; } & Omit; @@ -163,3 +165,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..b91654474 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..91d9a68b5 100644 --- a/backend/src/services/integration-auth/integration-sync-secret.ts +++ b/backend/src/services/integration-auth/integration-sync-secret.ts @@ -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, @@ -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 (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] */ @@ -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/hooks/api/integrationAuth/queries.tsx b/frontend/src/hooks/api/integrationAuth/queries.tsx index 45ac90a84..efb5ad7fb 100644 --- a/frontend/src/hooks/api/integrationAuth/queries.tsx +++ b/frontend/src/hooks/api/integrationAuth/queries.tsx @@ -120,16 +120,22 @@ const fetchIntegrationAuthById = async (integrationAuthId: string) => { const fetchIntegrationAuthApps = async ({ integrationAuthId, teamId, + azureDevOpsOrgName, workspaceSlug }: { integrationAuthId: string; teamId?: string; + azureDevOpsOrgName?: string; workspaceSlug?: string; }) => { const params: Record = {}; if (teamId) { params.teamId = teamId; } + if (azureDevOpsOrgName) { + params.azureDevOpsOrgName = azureDevOpsOrgName; + } + if (workspaceSlug) { params.workspaceSlug = workspaceSlug; } @@ -452,10 +458,12 @@ export const useGetIntegrationAuthById = (integrationAuthId: string) => { export const useGetIntegrationAuthApps = ({ integrationAuthId, teamId, + azureDevOpsOrgName, workspaceSlug }: { integrationAuthId: string; teamId?: string; + azureDevOpsOrgName?: string; workspaceSlug?: string; }) => { return useQuery({ @@ -464,6 +472,7 @@ export const useGetIntegrationAuthApps = ({ fetchIntegrationAuthApps({ integrationAuthId, teamId, + azureDevOpsOrgName, workspaceSlug }), enabled: true 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..989b1a17a --- /dev/null +++ b/frontend/src/pages/integrations/azure-devops/authorize.tsx @@ -0,0 +1,80 @@ +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 [devopsOrgName, setDevopsOrgName] = 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-name", devopsOrgName); + + 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)} /> + + + setDevopsOrgName(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..d34636106 --- /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) ?? "", + azureDevOpsOrgName: localStorage.getItem("azure-devops-org-name") ?? "" + }); + + 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-name") || "", + 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; }