feat(secret-sync): Add Azure Devops Secret Sync

This commit is contained in:
carlosmonastyrski
2025-06-05 13:57:41 -03:00
parent 83c53b9d5a
commit 7f8d5ec11a
53 changed files with 1683 additions and 3 deletions
@@ -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,237 @@
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 { getAzureDevopsConnectionAccessToken } 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 getAzureDevopsConnectionAccessToken(
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}/${orgName}/${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}/${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}/${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}/${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,
@@ -152,6 +154,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,
@@ -214,6 +221,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,
@@ -283,6 +296,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