Merge pull request #3736 from Infisical/feat/azureDevopsSecretSync

Feat/azure devops secret sync
This commit is contained in:
carlosmonastyrski
2025-06-12 22:06:05 -03:00
committed by GitHub
84 changed files with 2056 additions and 3 deletions
@@ -7,6 +7,7 @@ export enum AppConnection {
AzureKeyVault = "azure-key-vault",
AzureAppConfiguration = "azure-app-configuration",
AzureClientSecrets = "azure-client-secrets",
AzureDevOps = "azure-devops",
Humanitec = "humanitec",
TerraformCloud = "terraform-cloud",
Vercel = "vercel",
@@ -39,6 +39,11 @@ import {
getAzureClientSecretsConnectionListItem,
validateAzureClientSecretsConnectionCredentials
} from "./azure-client-secrets";
import { AzureDevOpsConnectionMethod } from "./azure-devops/azure-devops-enums";
import {
getAzureDevopsConnectionListItem,
validateAzureDevOpsConnectionCredentials
} from "./azure-devops/azure-devops-fns";
import {
AzureKeyVaultConnectionMethod,
getAzureKeyVaultConnectionListItem,
@@ -98,6 +103,7 @@ export const listAppConnectionOptions = () => {
getGcpConnectionListItem(),
getAzureKeyVaultConnectionListItem(),
getAzureAppConfigurationConnectionListItem(),
getAzureDevopsConnectionListItem(),
getDatabricksConnectionListItem(),
getHumanitecConnectionListItem(),
getTerraformCloudConnectionListItem(),
@@ -173,6 +179,7 @@ export const validateAppConnectionCredentials = async (
validateAzureAppConfigurationConnectionCredentials as TAppConnectionCredentialsValidator,
[AppConnection.AzureClientSecrets]:
validateAzureClientSecretsConnectionCredentials as TAppConnectionCredentialsValidator,
[AppConnection.AzureDevOps]: validateAzureDevOpsConnectionCredentials as TAppConnectionCredentialsValidator,
[AppConnection.Humanitec]: validateHumanitecConnectionCredentials as TAppConnectionCredentialsValidator,
[AppConnection.Postgres]: validateSqlConnectionCredentials as TAppConnectionCredentialsValidator,
[AppConnection.MsSql]: validateSqlConnectionCredentials as TAppConnectionCredentialsValidator,
@@ -201,6 +208,7 @@ export const getAppConnectionMethodName = (method: TAppConnection["method"]) =>
case AzureAppConfigurationConnectionMethod.OAuth:
case AzureClientSecretsConnectionMethod.OAuth:
case GitHubConnectionMethod.OAuth:
case AzureDevOpsConnectionMethod.OAuth:
return "OAuth";
case AwsConnectionMethod.AccessKey:
case OCIConnectionMethod.AccessKey:
@@ -225,6 +233,7 @@ export const getAppConnectionMethodName = (method: TAppConnection["method"]) =>
case WindmillConnectionMethod.AccessToken:
case HCVaultConnectionMethod.AccessToken:
case TeamCityConnectionMethod.AccessToken:
case AzureDevOpsConnectionMethod.AccessToken:
return "Access Token";
case Auth0ConnectionMethod.ClientCredentials:
return "Client Credentials";
@@ -270,6 +279,7 @@ export const TRANSITION_CONNECTION_CREDENTIALS_TO_PLATFORM: Record<
[AppConnection.GCP]: platformManagedCredentialsNotSupported,
[AppConnection.AzureKeyVault]: platformManagedCredentialsNotSupported,
[AppConnection.AzureAppConfiguration]: platformManagedCredentialsNotSupported,
[AppConnection.AzureDevOps]: platformManagedCredentialsNotSupported,
[AppConnection.Humanitec]: platformManagedCredentialsNotSupported,
[AppConnection.Postgres]: transferSqlConnectionCredentialsToPlatform as TAppConnectionTransitionCredentialsToPlatform,
[AppConnection.MsSql]: transferSqlConnectionCredentialsToPlatform as TAppConnectionTransitionCredentialsToPlatform,
@@ -8,6 +8,7 @@ export const APP_CONNECTION_NAME_MAP: Record<AppConnection, string> = {
[AppConnection.AzureKeyVault]: "Azure Key Vault",
[AppConnection.AzureAppConfiguration]: "Azure App Configuration",
[AppConnection.AzureClientSecrets]: "Azure Client Secrets",
[AppConnection.AzureDevOps]: "Azure DevOps",
[AppConnection.Databricks]: "Databricks",
[AppConnection.Humanitec]: "Humanitec",
[AppConnection.TerraformCloud]: "Terraform Cloud",
@@ -33,6 +34,7 @@ export const APP_CONNECTION_PLAN_MAP: Record<AppConnection, AppConnectionPlanTyp
[AppConnection.AzureKeyVault]: AppConnectionPlanType.Regular,
[AppConnection.AzureAppConfiguration]: AppConnectionPlanType.Regular,
[AppConnection.AzureClientSecrets]: AppConnectionPlanType.Regular,
[AppConnection.AzureDevOps]: AppConnectionPlanType.Regular,
[AppConnection.Databricks]: AppConnectionPlanType.Regular,
[AppConnection.Humanitec]: AppConnectionPlanType.Regular,
[AppConnection.TerraformCloud]: AppConnectionPlanType.Regular,
@@ -41,6 +41,8 @@ import { awsConnectionService } from "./aws/aws-connection-service";
import { ValidateAzureAppConfigurationConnectionCredentialsSchema } from "./azure-app-configuration";
import { ValidateAzureClientSecretsConnectionCredentialsSchema } from "./azure-client-secrets";
import { azureClientSecretsConnectionService } from "./azure-client-secrets/azure-client-secrets-service";
import { ValidateAzureDevOpsConnectionCredentialsSchema } from "./azure-devops/azure-devops-schemas";
import { azureDevOpsConnectionService } from "./azure-devops/azure-devops-service";
import { ValidateAzureKeyVaultConnectionCredentialsSchema } from "./azure-key-vault";
import { ValidateCamundaConnectionCredentialsSchema } from "./camunda";
import { camundaConnectionService } from "./camunda/camunda-connection-service";
@@ -84,6 +86,7 @@ const VALIDATE_APP_CONNECTION_CREDENTIALS_MAP: Record<AppConnection, TValidateAp
[AppConnection.GCP]: ValidateGcpConnectionCredentialsSchema,
[AppConnection.AzureKeyVault]: ValidateAzureKeyVaultConnectionCredentialsSchema,
[AppConnection.AzureAppConfiguration]: ValidateAzureAppConfigurationConnectionCredentialsSchema,
[AppConnection.AzureDevOps]: ValidateAzureDevOpsConnectionCredentialsSchema,
[AppConnection.Databricks]: ValidateDatabricksConnectionCredentialsSchema,
[AppConnection.Humanitec]: ValidateHumanitecConnectionCredentialsSchema,
[AppConnection.TerraformCloud]: ValidateTerraformCloudConnectionCredentialsSchema,
@@ -498,6 +501,7 @@ export const appConnectionServiceFactory = ({
camunda: camundaConnectionService(connectAppConnectionById, appConnectionDAL, kmsService),
vercel: vercelConnectionService(connectAppConnectionById),
azureClientSecrets: azureClientSecretsConnectionService(connectAppConnectionById, appConnectionDAL, kmsService),
azureDevOps: azureDevOpsConnectionService(connectAppConnectionById, appConnectionDAL, kmsService),
auth0: auth0ConnectionService(connectAppConnectionById, appConnectionDAL, kmsService),
hcvault: hcVaultConnectionService(connectAppConnectionById),
windmill: windmillConnectionService(connectAppConnectionById),
@@ -39,6 +39,12 @@ import {
TAzureClientSecretsConnectionInput,
TValidateAzureClientSecretsConnectionCredentialsSchema
} from "./azure-client-secrets";
import {
TAzureDevOpsConnection,
TAzureDevOpsConnectionConfig,
TAzureDevOpsConnectionInput,
TValidateAzureDevOpsConnectionCredentialsSchema
} from "./azure-devops/azure-devops-types";
import {
TAzureKeyVaultConnection,
TAzureKeyVaultConnectionConfig,
@@ -132,6 +138,7 @@ export type TAppConnection = { id: string } & (
| TGcpConnection
| TAzureKeyVaultConnection
| TAzureAppConfigurationConnection
| TAzureDevOpsConnection
| TDatabricksConnection
| THumanitecConnection
| TTerraformCloudConnection
@@ -161,6 +168,7 @@ export type TAppConnectionInput = { id: string } & (
| TGcpConnectionInput
| TAzureKeyVaultConnectionInput
| TAzureAppConfigurationConnectionInput
| TAzureDevOpsConnectionInput
| TDatabricksConnectionInput
| THumanitecConnectionInput
| TTerraformCloudConnectionInput
@@ -197,6 +205,7 @@ export type TAppConnectionConfig =
| TGcpConnectionConfig
| TAzureKeyVaultConnectionConfig
| TAzureAppConfigurationConnectionConfig
| TAzureDevOpsConnectionConfig
| TAzureClientSecretsConnectionConfig
| TDatabricksConnectionConfig
| THumanitecConnectionConfig
@@ -220,6 +229,7 @@ export type TValidateAppConnectionCredentialsSchema =
| TValidateAzureKeyVaultConnectionCredentialsSchema
| TValidateAzureAppConfigurationConnectionCredentialsSchema
| TValidateAzureClientSecretsConnectionCredentialsSchema
| TValidateAzureDevOpsConnectionCredentialsSchema
| TValidateDatabricksConnectionCredentialsSchema
| TValidateHumanitecConnectionCredentialsSchema
| TValidatePostgresConnectionCredentialsSchema
@@ -0,0 +1,4 @@
export enum AzureDevOpsConnectionMethod {
OAuth = "oauth",
AccessToken = "access-token"
}
@@ -0,0 +1,269 @@
/* eslint-disable no-case-declarations */
import { AxiosError, AxiosResponse } from "axios";
import { getConfig } from "@app/lib/config/env";
import { request } from "@app/lib/config/request";
import { BadRequestError, InternalServerError, NotFoundError } from "@app/lib/errors";
import { logger } from "@app/lib/logger";
import {
decryptAppConnectionCredentials,
encryptAppConnectionCredentials,
getAppConnectionMethodName
} from "@app/services/app-connection/app-connection-fns";
import { IntegrationUrls } from "@app/services/integration-auth/integration-list";
import { TKmsServiceFactory } from "@app/services/kms/kms-service";
import { TAppConnectionDALFactory } from "../app-connection-dal";
import { AppConnection } from "../app-connection-enums";
import { AzureDevOpsConnectionMethod } from "./azure-devops-enums";
import {
ExchangeCodeAzureResponse,
TAzureDevOpsConnectionConfig,
TAzureDevOpsConnectionCredentials
} from "./azure-devops-types";
export const getAzureDevopsConnectionListItem = () => {
const { INF_APP_CONNECTION_AZURE_CLIENT_ID } = getConfig();
return {
name: "Azure DevOps" as const,
app: AppConnection.AzureDevOps as const,
methods: Object.values(AzureDevOpsConnectionMethod) as [
AzureDevOpsConnectionMethod.OAuth,
AzureDevOpsConnectionMethod.AccessToken
],
oauthClientId: INF_APP_CONNECTION_AZURE_CLIENT_ID
};
};
export const getAzureDevopsConnection = async (
connectionId: string,
appConnectionDAL: Pick<TAppConnectionDALFactory, "findById" | "updateById">,
kmsService: Pick<TKmsServiceFactory, "createCipherPairWithDataKey">
) => {
const appConnection = await appConnectionDAL.findById(connectionId);
if (!appConnection) {
throw new NotFoundError({ message: `Connection with ID '${connectionId}' not found` });
}
if (appConnection.app !== AppConnection.AzureDevOps) {
throw new BadRequestError({
message: `Connection with ID '${connectionId}' is not an Azure DevOps connection`
});
}
const credentials = (await decryptAppConnectionCredentials({
orgId: appConnection.orgId,
kmsService,
encryptedCredentials: appConnection.encryptedCredentials
})) as TAzureDevOpsConnectionCredentials;
// Handle different connection methods
switch (appConnection.method) {
case AzureDevOpsConnectionMethod.OAuth:
const appCfg = getConfig();
if (!appCfg.INF_APP_CONNECTION_AZURE_CLIENT_ID || !appCfg.INF_APP_CONNECTION_AZURE_CLIENT_SECRET) {
throw new BadRequestError({
message: `Azure environment variables have not been configured`
});
}
if (!("refreshToken" in credentials)) {
throw new BadRequestError({ message: "Invalid OAuth credentials" });
}
const { refreshToken, tenantId } = credentials;
const currentTime = Date.now();
const { data } = await request.post<ExchangeCodeAzureResponse>(
IntegrationUrls.AZURE_TOKEN_URL.replace("common", tenantId || "common"),
new URLSearchParams({
grant_type: "refresh_token",
scope: `https://app.vssps.visualstudio.com/.default`,
client_id: appCfg.INF_APP_CONNECTION_AZURE_CLIENT_ID,
client_secret: appCfg.INF_APP_CONNECTION_AZURE_CLIENT_SECRET,
refresh_token: refreshToken
})
);
const updatedCredentials = {
...credentials,
accessToken: data.access_token,
expiresAt: currentTime + data.expires_in * 1000,
refreshToken: data.refresh_token
};
const encryptedCredentials = await encryptAppConnectionCredentials({
credentials: updatedCredentials,
orgId: appConnection.orgId,
kmsService
});
await appConnectionDAL.updateById(appConnection.id, { encryptedCredentials });
return data.access_token;
case AzureDevOpsConnectionMethod.AccessToken:
if (!("accessToken" in credentials)) {
throw new BadRequestError({ message: "Invalid API token credentials" });
}
// For access token, return the basic auth token directly
return credentials.accessToken;
default:
throw new BadRequestError({ message: `Unsupported connection method` });
}
};
export const validateAzureDevOpsConnectionCredentials = async (config: TAzureDevOpsConnectionConfig) => {
const { credentials: inputCredentials, method } = config;
const { INF_APP_CONNECTION_AZURE_CLIENT_ID, INF_APP_CONNECTION_AZURE_CLIENT_SECRET, SITE_URL } = getConfig();
switch (method) {
case AzureDevOpsConnectionMethod.OAuth:
if (!SITE_URL) {
throw new InternalServerError({ message: "SITE_URL env var is required to complete Azure OAuth flow" });
}
if (!INF_APP_CONNECTION_AZURE_CLIENT_ID || !INF_APP_CONNECTION_AZURE_CLIENT_SECRET) {
throw new InternalServerError({
message: `Azure ${getAppConnectionMethodName(method)} environment variables have not been configured`
});
}
let tokenResp: AxiosResponse<ExchangeCodeAzureResponse> | null = null;
let tokenError: AxiosError | null = null;
try {
const oauthCredentials = inputCredentials as { code: string; tenantId: string };
tokenResp = await request.post<ExchangeCodeAzureResponse>(
IntegrationUrls.AZURE_TOKEN_URL.replace("common", oauthCredentials.tenantId || "common"),
new URLSearchParams({
grant_type: "authorization_code",
code: oauthCredentials.code,
scope: `https://app.vssps.visualstudio.com/.default`,
client_id: INF_APP_CONNECTION_AZURE_CLIENT_ID,
client_secret: INF_APP_CONNECTION_AZURE_CLIENT_SECRET,
redirect_uri: `${SITE_URL}/organization/app-connections/azure/oauth/callback`
})
);
} catch (e: unknown) {
if (e instanceof AxiosError) {
tokenError = e;
} else {
throw new BadRequestError({
message: `Unable to validate connection: verify credentials`
});
}
}
if (tokenError) {
if (tokenError instanceof AxiosError) {
throw new BadRequestError({
message: `Failed to get access token: ${
(tokenError?.response?.data as { error_description?: string })?.error_description || "Unknown error"
}`
});
} else {
throw new InternalServerError({
message: "Failed to get access token"
});
}
}
if (!tokenResp) {
throw new InternalServerError({
message: `Failed to get access token: Token was empty with no error`
});
}
const oauthCredentials = inputCredentials as { code: string; tenantId: string; orgName: string };
return {
tenantId: oauthCredentials.tenantId,
orgName: oauthCredentials.orgName,
accessToken: tokenResp.data.access_token,
refreshToken: tokenResp.data.refresh_token,
expiresAt: Date.now() + tokenResp.data.expires_in * 1000
};
case AzureDevOpsConnectionMethod.AccessToken:
const accessTokenCredentials = inputCredentials as { accessToken: string; orgName?: string };
try {
if (accessTokenCredentials.orgName) {
// Validate against specific organization
const response = await request.get(
`${IntegrationUrls.AZURE_DEVOPS_API_URL}/${encodeURIComponent(accessTokenCredentials.orgName)}/_apis/projects?api-version=7.2-preview.2&$top=1`,
{
headers: {
Authorization: `Basic ${Buffer.from(`:${accessTokenCredentials.accessToken}`).toString("base64")}`
}
}
);
if (response.status !== 200) {
throw new BadRequestError({
message: `Failed to validate connection: ${response.status}`
});
}
return {
accessToken: accessTokenCredentials.accessToken,
orgName: accessTokenCredentials.orgName
};
}
// Validate via profile and discover organizations
const profileResponse = await request.get<{ displayName: string }>(
`https://app.vssps.visualstudio.com/_apis/profile/profiles/me?api-version=7.1`,
{
headers: {
Authorization: `Basic ${Buffer.from(`:${accessTokenCredentials.accessToken}`).toString("base64")}`
}
}
);
let organizations: Array<{ accountId: string; accountName: string; accountUri: string }> = [];
try {
const orgsResponse = await request.get<{
value: Array<{ accountId: string; accountName: string; accountUri: string }>;
}>(`https://app.vssps.visualstudio.com/_apis/accounts?api-version=7.1`, {
headers: {
Authorization: `Basic ${Buffer.from(`:${accessTokenCredentials.accessToken}`).toString("base64")}`
}
});
organizations = orgsResponse.data.value || [];
} catch (orgError) {
logger.warn(orgError, "Could not fetch organizations automatically:");
}
return {
accessToken: accessTokenCredentials.accessToken,
userDisplayName: profileResponse.data.displayName,
organizations: organizations.map((org) => ({
accountId: org.accountId,
accountName: org.accountName,
accountUri: org.accountUri
}))
};
} catch (error) {
if (error instanceof AxiosError) {
const errorMessage = accessTokenCredentials.orgName
? // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
`Failed to validate access token for organization '${accessTokenCredentials.orgName}': ${error.response?.data?.message || error.message}`
: `Invalid Azure DevOps Personal Access Token: ${error.response?.status === 401 ? "Token is invalid or expired" : error.message}`;
throw new BadRequestError({ message: errorMessage });
}
throw new BadRequestError({
message: `Unable to validate Azure DevOps token`
});
}
default:
throw new InternalServerError({
message: `Unhandled Azure connection method: ${method as AzureDevOpsConnectionMethod}`
});
}
};
@@ -0,0 +1,112 @@
import { z } from "zod";
import { AppConnections } from "@app/lib/api-docs";
import { AppConnection } from "@app/services/app-connection/app-connection-enums";
import {
BaseAppConnectionSchema,
GenericCreateAppConnectionFieldsSchema,
GenericUpdateAppConnectionFieldsSchema
} from "@app/services/app-connection/app-connection-schemas";
import { AzureDevOpsConnectionMethod } from "./azure-devops-enums";
export const AzureDevOpsConnectionOAuthInputCredentialsSchema = z.object({
code: z.string().trim().min(1, "OAuth code required").describe(AppConnections.CREDENTIALS.AZURE_DEVOPS.code),
tenantId: z.string().trim().min(1, "Tenant ID required").describe(AppConnections.CREDENTIALS.AZURE_DEVOPS.tenantId),
orgName: z
.string()
.trim()
.min(1, "Organization name required")
.describe(AppConnections.CREDENTIALS.AZURE_DEVOPS.orgName)
});
export const AzureDevOpsConnectionOAuthOutputCredentialsSchema = z.object({
tenantId: z.string(),
orgName: z.string(),
accessToken: z.string(),
refreshToken: z.string(),
expiresAt: z.number()
});
export const AzureDevOpsConnectionAccessTokenInputCredentialsSchema = z.object({
accessToken: z.string().trim().min(1, "Access Token required"),
orgName: z.string().trim().min(1, "Organization name required")
});
export const AzureDevOpsConnectionAccessTokenOutputCredentialsSchema = z.object({
accessToken: z.string(),
orgName: z.string()
});
export const ValidateAzureDevOpsConnectionCredentialsSchema = z.discriminatedUnion("method", [
z.object({
method: z
.literal(AzureDevOpsConnectionMethod.OAuth)
.describe(AppConnections.CREATE(AppConnection.AzureDevOps).method),
credentials: AzureDevOpsConnectionOAuthInputCredentialsSchema.describe(
AppConnections.CREATE(AppConnection.AzureDevOps).credentials
)
}),
z.object({
method: z
.literal(AzureDevOpsConnectionMethod.AccessToken)
.describe(AppConnections.CREATE(AppConnection.AzureDevOps).method),
credentials: AzureDevOpsConnectionAccessTokenInputCredentialsSchema.describe(
AppConnections.CREATE(AppConnection.AzureDevOps).credentials
)
})
]);
export const CreateAzureDevOpsConnectionSchema = ValidateAzureDevOpsConnectionCredentialsSchema.and(
GenericCreateAppConnectionFieldsSchema(AppConnection.AzureDevOps)
);
export const UpdateAzureDevOpsConnectionSchema = z
.object({
credentials: z
.union([AzureDevOpsConnectionOAuthInputCredentialsSchema, AzureDevOpsConnectionAccessTokenInputCredentialsSchema])
.optional()
.describe(AppConnections.UPDATE(AppConnection.AzureDevOps).credentials)
})
.and(GenericUpdateAppConnectionFieldsSchema(AppConnection.AzureDevOps));
const BaseAzureDevOpsConnectionSchema = BaseAppConnectionSchema.extend({
app: z.literal(AppConnection.AzureDevOps)
});
export const AzureDevOpsConnectionSchema = z.intersection(
BaseAzureDevOpsConnectionSchema,
z.discriminatedUnion("method", [
z.object({
method: z.literal(AzureDevOpsConnectionMethod.OAuth),
credentials: AzureDevOpsConnectionOAuthOutputCredentialsSchema
}),
z.object({
method: z.literal(AzureDevOpsConnectionMethod.AccessToken),
credentials: AzureDevOpsConnectionAccessTokenOutputCredentialsSchema
})
])
);
export const SanitizedAzureDevOpsConnectionSchema = z.discriminatedUnion("method", [
BaseAzureDevOpsConnectionSchema.extend({
method: z.literal(AzureDevOpsConnectionMethod.OAuth),
credentials: AzureDevOpsConnectionOAuthOutputCredentialsSchema.pick({
tenantId: true,
orgName: true
})
}),
BaseAzureDevOpsConnectionSchema.extend({
method: z.literal(AzureDevOpsConnectionMethod.AccessToken),
credentials: AzureDevOpsConnectionAccessTokenOutputCredentialsSchema.pick({
orgName: true
})
})
]);
export const AzureDevOpsConnectionListItemSchema = z.object({
name: z.literal("Azure DevOps"),
app: z.literal(AppConnection.AzureDevOps),
methods: z.nativeEnum(AzureDevOpsConnectionMethod).array(),
oauthClientId: z.string().optional()
});
@@ -0,0 +1,127 @@
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
/* eslint-disable no-case-declarations */
import { AxiosError } from "axios";
import { request } from "@app/lib/config/request";
import { BadRequestError } from "@app/lib/errors";
import { OrgServiceActor } from "@app/lib/types";
import { TAppConnectionDALFactory } from "@app/services/app-connection/app-connection-dal";
import { AppConnection } from "@app/services/app-connection/app-connection-enums";
import { IntegrationUrls } from "@app/services/integration-auth/integration-list";
import { TKmsServiceFactory } from "@app/services/kms/kms-service";
import { AzureDevOpsConnectionMethod } from "./azure-devops-enums";
import { getAzureDevopsConnection } from "./azure-devops-fns";
import { TAzureDevOpsConnection } from "./azure-devops-types";
type TGetAppConnectionFunc = (
app: AppConnection,
connectionId: string,
actor: OrgServiceActor
) => Promise<TAzureDevOpsConnection>;
type TAzureDevOpsProject = {
id: string;
name: string;
description?: string;
url?: string;
state?: string;
visibility?: string;
lastUpdateTime?: string;
revision?: number;
abbreviation?: string;
defaultTeamImageUrl?: string;
};
type TAzureDevOpsProjectsResponse = {
count: number;
value: TAzureDevOpsProject[];
};
const getAuthHeaders = (appConnection: TAzureDevOpsConnection, accessToken: string) => {
switch (appConnection.method) {
case AzureDevOpsConnectionMethod.OAuth:
return {
Authorization: `Bearer ${accessToken}`,
Accept: "application/json"
};
case AzureDevOpsConnectionMethod.AccessToken:
// For access token, create Basic auth header
const basicAuthToken = Buffer.from(`user:${accessToken}`).toString("base64");
return {
Authorization: `Basic ${basicAuthToken}`,
Accept: "application/json"
};
default:
throw new BadRequestError({ message: "Unsupported connection method" });
}
};
const listAzureDevOpsProjects = async (
appConnection: TAzureDevOpsConnection,
appConnectionDAL: Pick<TAppConnectionDALFactory, "findById" | "update" | "updateById">,
kmsService: Pick<TKmsServiceFactory, "createCipherPairWithDataKey">
): Promise<TAzureDevOpsProject[]> => {
const accessToken = await getAzureDevopsConnection(appConnection.id, appConnectionDAL, kmsService);
// Both OAuth and access Token methods use organization name from credentials
const credentials = appConnection.credentials as { orgName: string };
const { orgName } = credentials;
// Use the standard Azure DevOps Projects API endpoint
// This endpoint returns only projects that the authenticated user has access to
const devOpsEndpoint = `${IntegrationUrls.AZURE_DEVOPS_API_URL}/${encodeURIComponent(orgName)}/_apis/projects?api-version=7.1`;
try {
const { data } = await request.get<TAzureDevOpsProjectsResponse>(devOpsEndpoint, {
headers: getAuthHeaders(appConnection, accessToken)
});
return data.value || [];
} catch (error) {
if (error instanceof AxiosError) {
// Provide more specific error messages based on the response
if (error?.response?.status === 401) {
throw new Error(
`Authentication failed for Azure DevOps organization: ${orgName}. Please check your credentials and ensure the token has the required scopes (vso.project or vso.profile).`
);
} else if (error?.response?.status === 403) {
throw new Error(
`Access denied to Azure DevOps organization: ${orgName}. Please ensure the user has access to the organization.`
);
} else if (error?.response?.status === 404) {
throw new Error(`Azure DevOps organization not found: ${orgName}. Please verify the organization name.`);
}
}
throw error;
}
};
export const azureDevOpsConnectionService = (
getAppConnection: TGetAppConnectionFunc,
appConnectionDAL: Pick<TAppConnectionDALFactory, "findById" | "update" | "updateById">,
kmsService: Pick<TKmsServiceFactory, "createCipherPairWithDataKey">
) => {
const listProjects = async (connectionId: string, actor: OrgServiceActor) => {
const appConnection = await getAppConnection(AppConnection.AzureDevOps, connectionId, actor);
const projects = await listAzureDevOpsProjects(appConnection, appConnectionDAL, kmsService);
return projects.map((project) => ({
id: project.id,
name: project.name,
appId: project.id,
description: project.description,
url: project.url,
state: project.state,
visibility: project.visibility,
lastUpdateTime: project.lastUpdateTime,
revision: project.revision,
abbreviation: project.abbreviation,
defaultTeamImageUrl: project.defaultTeamImageUrl
}));
};
return {
listProjects
};
};
@@ -0,0 +1,54 @@
import { z } from "zod";
import { DiscriminativePick } from "@app/lib/types";
import { AppConnection } from "../app-connection-enums";
import {
AzureDevOpsConnectionOAuthOutputCredentialsSchema,
AzureDevOpsConnectionSchema,
CreateAzureDevOpsConnectionSchema,
ValidateAzureDevOpsConnectionCredentialsSchema
} from "./azure-devops-schemas";
export type TAzureDevOpsConnection = z.infer<typeof AzureDevOpsConnectionSchema>;
export type TAzureDevOpsConnectionInput = z.infer<typeof CreateAzureDevOpsConnectionSchema> & {
app: AppConnection.AzureDevOps;
};
export type TValidateAzureDevOpsConnectionCredentialsSchema = typeof ValidateAzureDevOpsConnectionCredentialsSchema;
export type TAzureDevOpsConnectionConfig = DiscriminativePick<
TAzureDevOpsConnectionInput,
"method" | "app" | "credentials"
> & {
orgId: string;
};
export type TAzureDevOpsConnectionCredentials = z.infer<typeof AzureDevOpsConnectionOAuthOutputCredentialsSchema>;
export interface ExchangeCodeAzureResponse {
token_type: string;
scope: string;
expires_in: number;
ext_expires_in: number;
access_token: string;
refresh_token: string;
id_token: string;
}
export interface TAzureRegisteredApp {
id: string;
appId: string;
displayName: string;
description?: string;
createdDateTime: string;
identifierUris?: string[];
signInAudience?: string;
}
export interface TAzureListRegisteredAppsResponse {
"@odata.context": string;
"@odata.nextLink"?: string;
value: TAzureRegisteredApp[];
}
@@ -0,0 +1,10 @@
import { AppConnection } from "@app/services/app-connection/app-connection-enums";
import { SecretSync } from "@app/services/secret-sync/secret-sync-enums";
import { TSecretSyncListItem } from "@app/services/secret-sync/secret-sync-types";
export const AZURE_DEVOPS_SYNC_LIST_OPTION: TSecretSyncListItem = {
name: "Azure DevOps",
destination: SecretSync.AzureDevOps,
connection: AppConnection.AzureDevOps,
canImportSecrets: false
};
@@ -0,0 +1,233 @@
import { request } from "@app/lib/config/request";
import { BadRequestError } from "@app/lib/errors";
import { TAppConnectionDALFactory } from "@app/services/app-connection/app-connection-dal";
import { AzureDevOpsConnectionMethod } from "@app/services/app-connection/azure-devops/azure-devops-enums";
import { getAzureDevopsConnection } from "@app/services/app-connection/azure-devops/azure-devops-fns";
import { IntegrationUrls } from "@app/services/integration-auth/integration-list";
import { TKmsServiceFactory } from "@app/services/kms/kms-service";
import { TSecretMap } from "@app/services/secret-sync/secret-sync-types";
import { TAzureDevOpsSyncWithCredentials } from "./azure-devops-sync-types";
type TAzureDevOpsSyncFactoryDeps = {
appConnectionDAL: Pick<TAppConnectionDALFactory, "findById" | "updateById">;
kmsService: Pick<TKmsServiceFactory, "createCipherPairWithDataKey">;
};
interface AzureDevOpsVariableGroup {
id: number;
name: string;
description: string;
type: string;
variables: Record<string, { value: string; isSecret: boolean }>;
variableGroupProjectReferences: Array<{
description: string;
name: string;
projectReference: { id: string; name: string };
}>;
}
interface AzureDevOpsVariableGroupList {
count: number;
value: AzureDevOpsVariableGroup[];
}
export const azureDevOpsSyncFactory = ({ kmsService, appConnectionDAL }: TAzureDevOpsSyncFactoryDeps) => {
const getConnectionAuth = async (secretSync: TAzureDevOpsSyncWithCredentials) => {
const { credentials } = secretSync.connection;
const isOAuth = secretSync.connection.method === AzureDevOpsConnectionMethod.OAuth;
const { orgName } = credentials;
if (!orgName) {
throw new BadRequestError({
message: "Azure DevOps: organization name is required"
});
}
const accessToken = await getAzureDevopsConnection(secretSync.connectionId, appConnectionDAL, kmsService);
return { accessToken, orgName, isOAuth };
};
const getAuthHeader = (accessToken: string, isOAuth: boolean) => {
if (isOAuth) {
return `Bearer ${accessToken}`;
}
const basicAuth = Buffer.from(`:${accessToken}`).toString("base64");
return `Basic ${basicAuth}`;
};
const $getEnvGroupId = async (
accessToken: string,
orgName: string,
projectId: string,
environmentName: string,
isOAuth: boolean
) => {
const url = `${IntegrationUrls.AZURE_DEVOPS_API_URL}/${encodeURIComponent(orgName)}/${encodeURIComponent(projectId)}/_apis/distributedtask/variablegroups?api-version=7.1`;
const response = await request.get<AzureDevOpsVariableGroupList>(url, {
headers: {
Authorization: getAuthHeader(accessToken, isOAuth)
}
});
for (const group of response.data.value) {
if (group.name === environmentName) {
return { groupId: group.id.toString(), groupName: group.name };
}
}
return { groupId: "", groupName: "" };
};
const syncSecrets = async (secretSync: TAzureDevOpsSyncWithCredentials, secretMap: TSecretMap) => {
if (!secretSync.destinationConfig.devopsProjectId) {
throw new BadRequestError({
message: "Azure DevOps: project ID is required"
});
}
if (!secretSync.environment?.name) {
throw new BadRequestError({
message: "Azure DevOps: environment name is required"
});
}
const { accessToken, orgName, isOAuth } = await getConnectionAuth(secretSync);
const { groupId, groupName } = await $getEnvGroupId(
accessToken,
orgName,
secretSync.destinationConfig.devopsProjectId,
secretSync.environment.name,
isOAuth
);
const variables: Record<string, { value: string; isSecret: boolean }> = {};
for (const [key, secret] of Object.entries(secretMap)) {
if (secret?.value !== undefined) {
variables[key] = { value: secret.value, isSecret: true };
}
}
if (!groupId) {
// Create new variable group - API endpoint is organization-level
const url = `${IntegrationUrls.AZURE_DEVOPS_API_URL}/${encodeURIComponent(orgName)}/_apis/distributedtask/variablegroups?api-version=7.1`;
await request.post(
url,
{
name: secretSync.environment.name,
description: secretSync.environment.name,
type: "Vsts",
variables,
variableGroupProjectReferences: [
{
description: secretSync.environment.name,
name: secretSync.environment.name,
projectReference: {
id: secretSync.destinationConfig.devopsProjectId,
name: secretSync.destinationConfig.devopsProjectId
}
}
]
},
{
headers: {
Authorization: getAuthHeader(accessToken, isOAuth),
"Content-Type": "application/json"
}
}
);
} else {
const url = `${IntegrationUrls.AZURE_DEVOPS_API_URL}/${encodeURIComponent(orgName)}/_apis/distributedtask/variablegroups/${groupId}?api-version=7.1`;
await request.put(
url,
{
name: groupName,
description: groupName,
type: "Vsts",
variables,
variableGroupProjectReferences: [
{
description: groupName,
name: groupName,
projectReference: {
id: secretSync.destinationConfig.devopsProjectId,
name: secretSync.destinationConfig.devopsProjectId
}
}
]
},
{
headers: {
Authorization: getAuthHeader(accessToken, isOAuth),
"Content-Type": "application/json"
}
}
);
}
};
const removeSecrets = async (secretSync: TAzureDevOpsSyncWithCredentials) => {
const { accessToken, orgName, isOAuth } = await getConnectionAuth(secretSync);
const { groupId } = await $getEnvGroupId(
accessToken,
orgName,
secretSync.destinationConfig.devopsProjectId,
secretSync.environment?.name || "",
isOAuth
);
if (groupId) {
// Delete the variable group entirely using the DELETE API
const deleteUrl = `${IntegrationUrls.AZURE_DEVOPS_API_URL}/${encodeURIComponent(orgName)}/_apis/distributedtask/variablegroups/${groupId}?projectIds=${secretSync.destinationConfig.devopsProjectId}&api-version=7.1`;
await request.delete(deleteUrl, {
headers: {
Authorization: getAuthHeader(accessToken, isOAuth)
}
});
}
};
const getSecrets = async (secretSync: TAzureDevOpsSyncWithCredentials) => {
const { accessToken, orgName, isOAuth } = await getConnectionAuth(secretSync);
const { groupId } = await $getEnvGroupId(
accessToken,
orgName,
secretSync.destinationConfig.devopsProjectId,
secretSync.environment?.name || "",
isOAuth
);
const secretMap: TSecretMap = {};
if (groupId) {
const url = `${IntegrationUrls.AZURE_DEVOPS_API_URL}/${orgName}/_apis/distributedtask/variablegroups/${groupId}?api-version=7.1`;
const response = await request.get<AzureDevOpsVariableGroup>(url, {
headers: {
Authorization: getAuthHeader(accessToken, isOAuth)
}
});
if (response?.data?.variables) {
Object.entries(response.data.variables).forEach(([key, variable]) => {
secretMap[key] = {
value: variable.value || ""
};
});
}
}
return secretMap;
};
return {
syncSecrets,
removeSecrets,
getSecrets
};
};
@@ -0,0 +1,50 @@
import { z } from "zod";
import { SecretSyncs } from "@app/lib/api-docs";
import { AppConnection } from "@app/services/app-connection/app-connection-enums";
import { SecretSync } from "@app/services/secret-sync/secret-sync-enums";
import {
BaseSecretSyncSchema,
GenericCreateSecretSyncFieldsSchema,
GenericUpdateSecretSyncFieldsSchema
} from "@app/services/secret-sync/secret-sync-schemas";
import { TSyncOptionsConfig } from "@app/services/secret-sync/secret-sync-types";
export const AzureDevOpsSyncDestinationConfigSchema = z.object({
devopsProjectId: z
.string()
.min(1, "Project ID required")
.describe(SecretSyncs.DESTINATION_CONFIG.AZURE_DEVOPS?.devopsProjectId || "Azure DevOps Project ID"),
devopsProjectName: z
.string()
.min(1, "Project name required")
.describe(SecretSyncs.DESTINATION_CONFIG.AZURE_DEVOPS?.devopsProjectName || "Azure DevOps Project Name")
});
const AzureDevOpsSyncOptionsConfig: TSyncOptionsConfig = { canImportSecrets: false };
export const AzureDevOpsSyncSchema = BaseSecretSyncSchema(SecretSync.AzureDevOps, AzureDevOpsSyncOptionsConfig).extend({
destination: z.literal(SecretSync.AzureDevOps),
destinationConfig: AzureDevOpsSyncDestinationConfigSchema
});
export const CreateAzureDevOpsSyncSchema = GenericCreateSecretSyncFieldsSchema(
SecretSync.AzureDevOps,
AzureDevOpsSyncOptionsConfig
).extend({
destinationConfig: AzureDevOpsSyncDestinationConfigSchema
});
export const UpdateAzureDevOpsSyncSchema = GenericUpdateSecretSyncFieldsSchema(
SecretSync.AzureDevOps,
AzureDevOpsSyncOptionsConfig
).extend({
destinationConfig: AzureDevOpsSyncDestinationConfigSchema.optional()
});
export const AzureDevOpsSyncListItemSchema = z.object({
name: z.literal("Azure DevOps"),
connection: z.literal(AppConnection.AzureDevOps),
destination: z.literal(SecretSync.AzureDevOps),
canImportSecrets: z.literal(false)
});
@@ -0,0 +1,22 @@
import { z } from "zod";
import { TAzureDevOpsConnection } from "@app/services/app-connection/azure-devops/azure-devops-types";
import {
AzureDevOpsSyncDestinationConfigSchema,
AzureDevOpsSyncListItemSchema,
AzureDevOpsSyncSchema,
CreateAzureDevOpsSyncSchema
} from "./azure-devops-sync-schemas";
export type TAzureDevOpsSync = z.infer<typeof AzureDevOpsSyncSchema>;
export type TAzureDevOpsSyncInput = z.infer<typeof CreateAzureDevOpsSyncSchema>;
export type TAzureDevOpsSyncListItem = z.infer<typeof AzureDevOpsSyncListItemSchema>;
export type TAzureDevOpsSyncDestinationConfig = z.infer<typeof AzureDevOpsSyncDestinationConfigSchema>;
export type TAzureDevOpsSyncWithCredentials = TAzureDevOpsSync & {
connection: TAzureDevOpsConnection;
};
@@ -0,0 +1,4 @@
export * from "./azure-devops-sync-constants";
export * from "./azure-devops-sync-fns";
export * from "./azure-devops-sync-schemas";
export * from "./azure-devops-sync-types";
@@ -5,6 +5,7 @@ export enum SecretSync {
GCPSecretManager = "gcp-secret-manager",
AzureKeyVault = "azure-key-vault",
AzureAppConfiguration = "azure-app-configuration",
AzureDevOps = "azure-devops",
Databricks = "databricks",
Humanitec = "humanitec",
TerraformCloud = "terraform-cloud",
@@ -26,6 +26,7 @@ import { TAppConnectionDALFactory } from "../app-connection/app-connection-dal";
import { TKmsServiceFactory } from "../kms/kms-service";
import { ONEPASS_SYNC_LIST_OPTION, OnePassSyncFns } from "./1password";
import { AZURE_APP_CONFIGURATION_SYNC_LIST_OPTION, azureAppConfigurationSyncFactory } from "./azure-app-configuration";
import { AZURE_DEVOPS_SYNC_LIST_OPTION, azureDevOpsSyncFactory } from "./azure-devops";
import { AZURE_KEY_VAULT_SYNC_LIST_OPTION, azureKeyVaultSyncFactory } from "./azure-key-vault";
import { CAMUNDA_SYNC_LIST_OPTION, camundaSyncFactory } from "./camunda";
import { GCP_SYNC_LIST_OPTION } from "./gcp";
@@ -45,6 +46,7 @@ const SECRET_SYNC_LIST_OPTIONS: Record<SecretSync, TSecretSyncListItem> = {
[SecretSync.GitHub]: GITHUB_SYNC_LIST_OPTION,
[SecretSync.GCPSecretManager]: GCP_SYNC_LIST_OPTION,
[SecretSync.AzureKeyVault]: AZURE_KEY_VAULT_SYNC_LIST_OPTION,
[SecretSync.AzureDevOps]: AZURE_DEVOPS_SYNC_LIST_OPTION,
[SecretSync.AzureAppConfiguration]: AZURE_APP_CONFIGURATION_SYNC_LIST_OPTION,
[SecretSync.Databricks]: DATABRICKS_SYNC_LIST_OPTION,
[SecretSync.Humanitec]: HUMANITEC_SYNC_LIST_OPTION,
@@ -182,6 +184,11 @@ export const SecretSyncFns = {
appConnectionDAL,
kmsService
}).syncSecrets(secretSync, schemaSecretMap);
case SecretSync.AzureDevOps:
return azureDevOpsSyncFactory({
appConnectionDAL,
kmsService
}).syncSecrets(secretSync, schemaSecretMap);
case SecretSync.Databricks:
return databricksSyncFactory({
appConnectionDAL,
@@ -244,6 +251,12 @@ export const SecretSyncFns = {
kmsService
}).getSecrets(secretSync);
break;
case SecretSync.AzureDevOps:
secretMap = await azureDevOpsSyncFactory({
appConnectionDAL,
kmsService
}).getSecrets(secretSync);
break;
case SecretSync.Databricks:
return databricksSyncFactory({
appConnectionDAL,
@@ -315,6 +328,11 @@ export const SecretSyncFns = {
appConnectionDAL,
kmsService
}).removeSecrets(secretSync, schemaSecretMap);
case SecretSync.AzureDevOps:
return azureDevOpsSyncFactory({
appConnectionDAL,
kmsService
}).removeSecrets(secretSync);
case SecretSync.Databricks:
return databricksSyncFactory({
appConnectionDAL,
@@ -8,6 +8,7 @@ export const SECRET_SYNC_NAME_MAP: Record<SecretSync, string> = {
[SecretSync.GCPSecretManager]: "GCP Secret Manager",
[SecretSync.AzureKeyVault]: "Azure Key Vault",
[SecretSync.AzureAppConfiguration]: "Azure App Configuration",
[SecretSync.AzureDevOps]: "Azure DevOps",
[SecretSync.Databricks]: "Databricks",
[SecretSync.Humanitec]: "Humanitec",
[SecretSync.TerraformCloud]: "Terraform Cloud",
@@ -27,6 +28,7 @@ export const SECRET_SYNC_CONNECTION_MAP: Record<SecretSync, AppConnection> = {
[SecretSync.GCPSecretManager]: AppConnection.GCP,
[SecretSync.AzureKeyVault]: AppConnection.AzureKeyVault,
[SecretSync.AzureAppConfiguration]: AppConnection.AzureAppConfiguration,
[SecretSync.AzureDevOps]: AppConnection.AzureDevOps,
[SecretSync.Databricks]: AppConnection.Databricks,
[SecretSync.Humanitec]: AppConnection.Humanitec,
[SecretSync.TerraformCloud]: AppConnection.TerraformCloud,
@@ -46,6 +48,7 @@ export const SECRET_SYNC_PLAN_MAP: Record<SecretSync, SecretSyncPlanType> = {
[SecretSync.GCPSecretManager]: SecretSyncPlanType.Regular,
[SecretSync.AzureKeyVault]: SecretSyncPlanType.Regular,
[SecretSync.AzureAppConfiguration]: SecretSyncPlanType.Regular,
[SecretSync.AzureDevOps]: SecretSyncPlanType.Regular,
[SecretSync.Databricks]: SecretSyncPlanType.Regular,
[SecretSync.Humanitec]: SecretSyncPlanType.Regular,
[SecretSync.TerraformCloud]: SecretSyncPlanType.Regular,
@@ -60,6 +60,12 @@ import {
TAzureAppConfigurationSyncListItem,
TAzureAppConfigurationSyncWithCredentials
} from "./azure-app-configuration";
import {
TAzureDevOpsSync,
TAzureDevOpsSyncInput,
TAzureDevOpsSyncListItem,
TAzureDevOpsSyncWithCredentials
} from "./azure-devops";
import {
TAzureKeyVaultSync,
TAzureKeyVaultSyncInput,
@@ -100,6 +106,7 @@ export type TSecretSync =
| TGcpSync
| TAzureKeyVaultSync
| TAzureAppConfigurationSync
| TAzureDevOpsSync
| TDatabricksSync
| THumanitecSync
| TTerraformCloudSync
@@ -118,6 +125,7 @@ export type TSecretSyncWithCredentials =
| TGcpSyncWithCredentials
| TAzureKeyVaultSyncWithCredentials
| TAzureAppConfigurationSyncWithCredentials
| TAzureDevOpsSyncWithCredentials
| TDatabricksSyncWithCredentials
| THumanitecSyncWithCredentials
| TTerraformCloudSyncWithCredentials
@@ -136,6 +144,7 @@ export type TSecretSyncInput =
| TGcpSyncInput
| TAzureKeyVaultSyncInput
| TAzureAppConfigurationSyncInput
| TAzureDevOpsSyncInput
| TDatabricksSyncInput
| THumanitecSyncInput
| TTerraformCloudSyncInput
@@ -154,6 +163,7 @@ export type TSecretSyncListItem =
| TGcpSyncListItem
| TAzureKeyVaultSyncListItem
| TAzureAppConfigurationSyncListItem
| TAzureDevOpsSyncListItem
| TDatabricksSyncListItem
| THumanitecSyncListItem
| TTerraformCloudSyncListItem