Feat: Azure DevOps Integration

This commit is contained in:
Daniel Hougaard
2024-08-20 17:13:41 +04:00
parent c130fbddd9
commit 326764dd41
10 changed files with 428 additions and 12 deletions

View File

@@ -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;
};

View File

@@ -17,7 +17,7 @@ export const getDefaultOnPremFeatures = (): TFeatureSet => ({
environmentsUsed: 0,
identityLimit: null,
identitiesUsed: 0,
dynamicSecret: false,
dynamicSecret: true,
secretVersioning: true,
pitRecovery: false,
ipAllowlisting: false,

View File

@@ -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<string, string>[] }>(
`${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" });
}

View File

@@ -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;
};

View File

@@ -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: ""
}
];

View File

@@ -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<string, { value: string; comment?: string }>;
accessToken: string;
createManySecretsRawFn: (params: TCreateManySecretsRawFn) => Promise<Array<{ id: string }>>;
updateManySecretsRawFn: (params: TUpdateManySecretsRawFn) => Promise<Array<{ id: string }>>;
createManySecretsRawFn: (params: TCreateManySecretsRawFn) => Promise<Array<TSecrets & { _id: string }>>;
updateManySecretsRawFn: (params: TUpdateManySecretsRawFn) => Promise<Array<TSecrets & { _id: string }>>;
}) => {
interface GetAzureKeyVaultSecret {
id: string; // secret URI
@@ -966,8 +967,8 @@ const syncSecretsHeroku = async ({
secrets,
accessToken
}: {
createManySecretsRawFn: (params: TCreateManySecretsRawFn) => Promise<Array<{ id: string }>>;
updateManySecretsRawFn: (params: TUpdateManySecretsRawFn) => Promise<Array<{ id: string }>>;
createManySecretsRawFn: (params: TCreateManySecretsRawFn) => Promise<Array<TSecrets & { _id: string }>>;
updateManySecretsRawFn: (params: TUpdateManySecretsRawFn) => Promise<Array<TSecrets & { _id: string }>>;
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<string, { value: string; comment?: string }>;
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<string, { value: string }> = {};
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<Array<{ id: string }>>;
updateManySecretsRawFn: (params: TUpdateManySecretsRawFn) => Promise<Array<{ id: string }>>;
createManySecretsRawFn: (params: TCreateManySecretsRawFn) => Promise<Array<TSecrets & { _id: string }>>;
updateManySecretsRawFn: (params: TUpdateManySecretsRawFn) => Promise<Array<TSecrets & { _id: string }>>;
integration: TIntegrations & {
projectId: string;
environment: {
@@ -3675,8 +3786,8 @@ export const syncIntegrationSecrets = async ({
appendices,
projectId
}: {
createManySecretsRawFn: (params: TCreateManySecretsRawFn) => Promise<Array<{ id: string }>>;
updateManySecretsRawFn: (params: TUpdateManySecretsRawFn) => Promise<Array<{ id: string }>>;
createManySecretsRawFn: (params: TCreateManySecretsRawFn) => Promise<Array<TSecrets & { _id: string }>>;
updateManySecretsRawFn: (params: TUpdateManySecretsRawFn) => Promise<Array<TSecrets & { _id: string }>>;
integrationDAL: Pick<TIntegrationDALFactory, "updateById">;
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,

View File

@@ -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 = {

View File

@@ -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 (
<div className="flex h-full w-full items-center justify-center">
<Card className="max-w-md rounded-md p-8">
<CardTitle className="text-center">AzureDevops Integration</CardTitle>
<FormControl
label="AzureDevops API Token"
errorText={apiKeyErrorText}
isError={apiKeyErrorText !== "" ?? false}
>
<Input placeholder="" value={apiKey} onChange={(e) => setApiKey(e.target.value)} />
</FormControl>
<FormControl
label="AzureDevops Organization Slug"
tooltipText="This is not the organization ID, but the slug of the organization. An example would be 'my-acme-org'"
errorText={apiKeyErrorText}
isError={apiKeyErrorText !== "" ?? false}
>
<Input placeholder="" value={orgId} onChange={(e) => setOrgId(e.target.value)} />
</FormControl>
<Button
onClick={handleButtonClick}
color="mineshaft"
className="mt-4"
isLoading={isLoading}
>
Connect to AzureDevops
</Button>
</Card>
</div>
);
}
AzureDevopsCreateIntegrationPage.requireAuth = true;

View File

@@ -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 ? (
<div className="flex h-full w-full items-center justify-center">
<Card className="max-w-md rounded-md p-8">
<CardTitle className="text-center">AzureDevops Integration</CardTitle>
<FormControl label="Project Environment" className="mt-4">
<Select
value={selectedSourceEnvironment}
onValueChange={(val) => setSelectedSourceEnvironment(val)}
className="w-full border border-mineshaft-500"
>
{workspace?.environments.map((sourceEnvironment) => (
<SelectItem
value={sourceEnvironment.slug}
key={`source-environment-${sourceEnvironment.slug}`}
>
{sourceEnvironment.name}
</SelectItem>
))}
</Select>
</FormControl>
<FormControl label="Secrets Path">
<Input
value={secretPath}
onChange={(evt) => setSecretPath(evt.target.value)}
placeholder="Provide a path, default is /"
/>
</FormControl>
<FormControl label="AzureDevops Project" className="mt-4">
<Select
value={targetApp}
onValueChange={(val) => setTargetApp(val)}
className="w-full border border-mineshaft-500"
isDisabled={integrationAuthApps.length === 0}
>
{integrationAuthApps.length > 0 ? (
integrationAuthApps.map((integrationAuthApp) => (
<SelectItem
value={integrationAuthApp.name}
key={`target-environment-${integrationAuthApp.name}`}
>
{integrationAuthApp.name}
</SelectItem>
))
) : (
<SelectItem value="none" key="target-app-none">
No projects found
</SelectItem>
)}
</Select>
</FormControl>
<Button
onClick={handleButtonClick}
color="mineshaft"
className="mt-4"
isLoading={isLoading}
isDisabled={integrationAuthApps.length === 0}
>
Create Integration
</Button>
</Card>
</div>
) : (
<div />
);
}
AzureDevopsCreateIntegrationPage.requireAuth = true;

View File

@@ -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;
}