From 7f8d5ec11ae20c978757a5745fa1a631f62ba7c6 Mon Sep 17 00:00:00 2001 From: carlosmonastyrski Date: Thu, 5 Jun 2025 13:57:41 -0300 Subject: [PATCH] feat(secret-sync): Add Azure Devops Secret Sync --- backend/src/lib/api-docs/constants.ts | 9 + .../app-connection-router.ts | 6 + .../azure-devops-connection-router.ts | 49 +++ .../routes/v1/app-connection-routers/index.ts | 2 + .../azure-devops-sync-router.ts | 17 + .../routes/v1/secret-sync-routers/index.ts | 2 + .../secret-sync-routers/secret-sync-router.ts | 3 + .../app-connection/app-connection-enums.ts | 1 + .../app-connection/app-connection-fns.ts | 10 + .../app-connection/app-connection-maps.ts | 2 + .../app-connection/app-connection-service.ts | 4 + .../app-connection/app-connection-types.ts | 10 + .../azure-devops/azure-devops-enums.ts | 4 + .../azure-devops/azure-devops-fns.ts | 269 ++++++++++++++++ .../azure-devops/azure-devops-schemas.ts | 112 +++++++ .../azure-devops/azure-devops-service.ts | 127 ++++++++ .../azure-devops/azure-devops-types.ts | 62 ++++ .../azure-devops-sync-constants.ts | 10 + .../azure-devops/azure-devops-sync-fns.ts | 237 ++++++++++++++ .../azure-devops/azure-devops-sync-schemas.ts | 50 +++ .../azure-devops/azure-devops-sync-types.ts | 22 ++ .../secret-sync/azure-devops/index.ts | 4 + .../services/secret-sync/secret-sync-enums.ts | 1 + .../services/secret-sync/secret-sync-fns.ts | 18 ++ .../services/secret-sync/secret-sync-maps.ts | 3 + .../services/secret-sync/secret-sync-types.ts | 10 + .../AzureDevopsSyncFields.tsx | 76 +++++ .../SecretSyncDestinationFields.tsx | 3 + .../SecretSyncOptionsFields.tsx | 1 + .../AzureDevopsSyncReviewFields.tsx | 18 ++ .../SecretSyncReviewFields.tsx | 4 + .../azure-devops-sync-destination-schema.ts | 17 + .../forms/schemas/secret-sync-schema.ts | 2 + frontend/src/helpers/appConnections.ts | 4 + frontend/src/helpers/secretSyncs.ts | 5 + .../api/appConnections/azure/queries.tsx | 32 +- .../hooks/api/appConnections/azure/types.ts | 10 + .../src/hooks/api/appConnections/enums.ts | 1 + .../api/appConnections/types/app-options.ts | 7 + .../types/azure-devops-connection.ts | 27 ++ .../hooks/api/appConnections/types/index.ts | 4 + frontend/src/hooks/api/secretSyncs/enums.ts | 1 + .../secretSyncs/types/azure-devops-sync.ts | 16 + .../src/hooks/api/secretSyncs/types/index.ts | 2 + .../AppConnectionForm/AppConnectionForm.tsx | 5 + .../AzureDevOpsConnectionForm.tsx | 291 ++++++++++++++++++ .../OauthCallbackPage/OauthCallbackPage.tsx | 77 ++++- .../AzureDevopsDestinationCol.tsx | 14 + .../SecretSyncDestinationCol.tsx | 3 + .../SecretSyncTable/helpers/index.ts | 3 + .../AzureDevopsSyncDestinationSection.tsx | 14 + .../SecretSyncDestinatonSection.tsx | 4 + .../SecretSyncOptionsSection.tsx | 1 + 53 files changed, 1683 insertions(+), 3 deletions(-) create mode 100644 backend/src/server/routes/v1/app-connection-routers/azure-devops-connection-router.ts create mode 100644 backend/src/server/routes/v1/secret-sync-routers/azure-devops-sync-router.ts create mode 100644 backend/src/services/app-connection/azure-devops/azure-devops-enums.ts create mode 100644 backend/src/services/app-connection/azure-devops/azure-devops-fns.ts create mode 100644 backend/src/services/app-connection/azure-devops/azure-devops-schemas.ts create mode 100644 backend/src/services/app-connection/azure-devops/azure-devops-service.ts create mode 100644 backend/src/services/app-connection/azure-devops/azure-devops-types.ts create mode 100644 backend/src/services/secret-sync/azure-devops/azure-devops-sync-constants.ts create mode 100644 backend/src/services/secret-sync/azure-devops/azure-devops-sync-fns.ts create mode 100644 backend/src/services/secret-sync/azure-devops/azure-devops-sync-schemas.ts create mode 100644 backend/src/services/secret-sync/azure-devops/azure-devops-sync-types.ts create mode 100644 backend/src/services/secret-sync/azure-devops/index.ts create mode 100644 frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/AzureDevopsSyncFields.tsx create mode 100644 frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/AzureDevopsSyncReviewFields.tsx create mode 100644 frontend/src/components/secret-syncs/forms/schemas/azure-devops-sync-destination-schema.ts create mode 100644 frontend/src/hooks/api/appConnections/types/azure-devops-connection.ts create mode 100644 frontend/src/hooks/api/secretSyncs/types/azure-devops-sync.ts create mode 100644 frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/AzureDevOpsConnectionForm.tsx create mode 100644 frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/SecretSyncDestinationCol/AzureDevopsDestinationCol.tsx create mode 100644 frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncDestinationSection/AzureDevopsSyncDestinationSection.tsx diff --git a/backend/src/lib/api-docs/constants.ts b/backend/src/lib/api-docs/constants.ts index 71e284ae6..b37153d34 100644 --- a/backend/src/lib/api-docs/constants.ts +++ b/backend/src/lib/api-docs/constants.ts @@ -2156,6 +2156,11 @@ export const AppConnections = { code: "The OAuth code to use to connect with Azure Client Secrets.", tenantId: "The Tenant ID to use to connect with Azure Client Secrets." }, + AZURE_DEVOPS: { + code: "The OAuth code to use to connect with Azure DevOps.", + tenantId: "The Tenant ID to use to connect with Azure DevOps.", + orgName: "The Organization name to use to connect with Azure DevOps." + }, OCI: { userOcid: "The OCID (Oracle Cloud Identifier) of the user making the request.", tenancyOcid: "The OCID (Oracle Cloud Identifier) of the tenancy in Oracle Cloud Infrastructure.", @@ -2270,6 +2275,10 @@ export const SecretSyncs = { "The URL of the Azure App Configuration to sync secrets to. Example: https://example.azconfig.io/", label: "An optional label to assign to secrets created in Azure App Configuration." }, + AZURE_DEVOPS: { + devopsProjectId: "The ID of the Azure DevOps project to sync secrets to.", + devopsProjectName: "The name of the Azure DevOps project to sync secrets to." + }, GCP: { scope: "The Google project scope that secrets should be synced to.", projectId: "The ID of the Google project secrets should be synced to." diff --git a/backend/src/server/routes/v1/app-connection-routers/app-connection-router.ts b/backend/src/server/routes/v1/app-connection-routers/app-connection-router.ts index f523bb218..eace88ff6 100644 --- a/backend/src/server/routes/v1/app-connection-routers/app-connection-router.ts +++ b/backend/src/server/routes/v1/app-connection-routers/app-connection-router.ts @@ -19,6 +19,10 @@ import { AzureClientSecretsConnectionListItemSchema, SanitizedAzureClientSecretsConnectionSchema } from "@app/services/app-connection/azure-client-secrets"; +import { + AzureDevOpsConnectionListItemSchema, + SanitizedAzureDevOpsConnectionSchema +} from "@app/services/app-connection/azure-devops/azure-devops-schemas"; import { AzureKeyVaultConnectionListItemSchema, SanitizedAzureKeyVaultConnectionSchema @@ -75,6 +79,7 @@ const SanitizedAppConnectionSchema = z.union([ ...SanitizedGcpConnectionSchema.options, ...SanitizedAzureKeyVaultConnectionSchema.options, ...SanitizedAzureAppConfigurationConnectionSchema.options, + ...SanitizedAzureDevOpsConnectionSchema.options, ...SanitizedDatabricksConnectionSchema.options, ...SanitizedHumanitecConnectionSchema.options, ...SanitizedTerraformCloudConnectionSchema.options, @@ -100,6 +105,7 @@ const AppConnectionOptionsSchema = z.discriminatedUnion("app", [ GcpConnectionListItemSchema, AzureKeyVaultConnectionListItemSchema, AzureAppConfigurationConnectionListItemSchema, + AzureDevOpsConnectionListItemSchema, DatabricksConnectionListItemSchema, HumanitecConnectionListItemSchema, TerraformCloudConnectionListItemSchema, diff --git a/backend/src/server/routes/v1/app-connection-routers/azure-devops-connection-router.ts b/backend/src/server/routes/v1/app-connection-routers/azure-devops-connection-router.ts new file mode 100644 index 000000000..a77c3d3dd --- /dev/null +++ b/backend/src/server/routes/v1/app-connection-routers/azure-devops-connection-router.ts @@ -0,0 +1,49 @@ +import { z } from "zod"; + +import { readLimit } from "@app/server/config/rateLimiter"; +import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; +import { + CreateAzureDevOpsConnectionSchema, + SanitizedAzureDevOpsConnectionSchema, + UpdateAzureDevOpsConnectionSchema +} from "@app/services/app-connection/azure-devops/azure-devops-schemas"; +import { AuthMode } from "@app/services/auth/auth-type"; + +import { registerAppConnectionEndpoints } from "./app-connection-endpoints"; + +export const registerAzureDevOpsConnectionRouter = async (server: FastifyZodProvider) => { + registerAppConnectionEndpoints({ + app: AppConnection.AzureDevOps, + server, + sanitizedResponseSchema: SanitizedAzureDevOpsConnectionSchema, + createSchema: CreateAzureDevOpsConnectionSchema, + updateSchema: UpdateAzureDevOpsConnectionSchema + }); + + server.route({ + method: "GET", + url: `/:connectionId/projects`, + config: { + rateLimit: readLimit + }, + schema: { + params: z.object({ + connectionId: z.string().uuid() + }), + response: { + 200: z.object({ + projects: z.object({ name: z.string(), id: z.string(), appId: z.string() }).array() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const { connectionId } = req.params; + + const projects = await server.services.appConnection.azureDevOps.listProjects(connectionId, req.permission); + + return { projects }; + } + }); +}; diff --git a/backend/src/server/routes/v1/app-connection-routers/index.ts b/backend/src/server/routes/v1/app-connection-routers/index.ts index 7085b3364..d20a52d54 100644 --- a/backend/src/server/routes/v1/app-connection-routers/index.ts +++ b/backend/src/server/routes/v1/app-connection-routers/index.ts @@ -6,6 +6,7 @@ import { registerAuth0ConnectionRouter } from "./auth0-connection-router"; import { registerAwsConnectionRouter } from "./aws-connection-router"; import { registerAzureAppConfigurationConnectionRouter } from "./azure-app-configuration-connection-router"; import { registerAzureClientSecretsConnectionRouter } from "./azure-client-secrets-connection-router"; +import { registerAzureDevOpsConnectionRouter } from "./azure-devops-connection-router"; import { registerAzureKeyVaultConnectionRouter } from "./azure-key-vault-connection-router"; import { registerCamundaConnectionRouter } from "./camunda-connection-router"; import { registerDatabricksConnectionRouter } from "./databricks-connection-router"; @@ -34,6 +35,7 @@ export const APP_CONNECTION_REGISTER_ROUTER_MAP: Record + registerSyncSecretsEndpoints({ + destination: SecretSync.AzureDevOps, + server, + responseSchema: AzureDevOpsSyncSchema, + createSchema: CreateAzureDevOpsSyncSchema, + updateSchema: UpdateAzureDevOpsSyncSchema + }); diff --git a/backend/src/server/routes/v1/secret-sync-routers/index.ts b/backend/src/server/routes/v1/secret-sync-routers/index.ts index fbc636ffc..3f4276726 100644 --- a/backend/src/server/routes/v1/secret-sync-routers/index.ts +++ b/backend/src/server/routes/v1/secret-sync-routers/index.ts @@ -5,6 +5,7 @@ import { registerOnePassSyncRouter } from "./1password-sync-router"; import { registerAwsParameterStoreSyncRouter } from "./aws-parameter-store-sync-router"; import { registerAwsSecretsManagerSyncRouter } from "./aws-secrets-manager-sync-router"; import { registerAzureAppConfigurationSyncRouter } from "./azure-app-configuration-sync-router"; +import { registerAzureDevOpsSyncRouter } from "./azure-devops-sync-router"; import { registerAzureKeyVaultSyncRouter } from "./azure-key-vault-sync-router"; import { registerCamundaSyncRouter } from "./camunda-sync-router"; import { registerDatabricksSyncRouter } from "./databricks-sync-router"; @@ -26,6 +27,7 @@ export const SECRET_SYNC_REGISTER_ROUTER_MAP: Record { 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: @@ -217,6 +225,7 @@ export const getAppConnectionMethodName = (method: TAppConnection["method"]) => case TerraformCloudConnectionMethod.ApiToken: case VercelConnectionMethod.ApiToken: case OnePassConnectionMethod.ApiToken: + case AzureDevOpsConnectionMethod.ApiToken: return "API Token"; case PostgresConnectionMethod.UsernameAndPassword: case MsSqlConnectionMethod.UsernameAndPassword: @@ -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, diff --git a/backend/src/services/app-connection/app-connection-maps.ts b/backend/src/services/app-connection/app-connection-maps.ts index 0042fdf42..80a208a66 100644 --- a/backend/src/services/app-connection/app-connection-maps.ts +++ b/backend/src/services/app-connection/app-connection-maps.ts @@ -8,6 +8,7 @@ export const APP_CONNECTION_NAME_MAP: Record = { [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 { + 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.ApiToken + ], + oauthClientId: INF_APP_CONNECTION_AZURE_CLIENT_ID + }; +}; + +export const getAzureDevopsConnectionAccessToken = async ( + connectionId: string, + appConnectionDAL: Pick, + kmsService: Pick +) => { + 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( + 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.ApiToken: + if (!("apiKey" in credentials)) { + throw new BadRequestError({ message: "Invalid API token credentials" }); + } + // For API token, return the basic auth token directly + return credentials.apiKey as string; + + 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 | null = null; + let tokenError: AxiosError | null = null; + + try { + const oauthCredentials = inputCredentials as { code: string; tenantId: string }; + tokenResp = await request.post( + 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.ApiToken: + const apiTokenCredentials = inputCredentials as { apiKey: string; orgName?: string }; + + try { + if (apiTokenCredentials.orgName) { + // Validate against specific organization + const response = await request.get( + `${IntegrationUrls.AZURE_DEVOPS_API_URL}/${apiTokenCredentials.orgName}/_apis/projects?api-version=7.2-preview.2&$top=1`, + { + headers: { + Authorization: `Basic ${Buffer.from(`:${apiTokenCredentials.apiKey}`).toString("base64")}` + } + } + ); + + if (response.status !== 200) { + throw new BadRequestError({ + message: `Failed to validate connection: ${response.status}` + }); + } + + return { + apiKey: apiTokenCredentials.apiKey, + orgName: apiTokenCredentials.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(`:${apiTokenCredentials.apiKey}`).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(`:${apiTokenCredentials.apiKey}`).toString("base64")}` + } + }); + organizations = orgsResponse.data.value || []; + } catch (orgError) { + logger.warn(orgError, "Could not fetch organizations automatically:"); + } + + return { + apiKey: apiTokenCredentials.apiKey, + 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 = apiTokenCredentials.orgName + ? // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + `Failed to validate API token for organization '${apiTokenCredentials.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}` + }); + } +}; diff --git a/backend/src/services/app-connection/azure-devops/azure-devops-schemas.ts b/backend/src/services/app-connection/azure-devops/azure-devops-schemas.ts new file mode 100644 index 000000000..60d2f6922 --- /dev/null +++ b/backend/src/services/app-connection/azure-devops/azure-devops-schemas.ts @@ -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 AzureDevOpsConnectionApiTokenInputCredentialsSchema = z.object({ + apiKey: z.string().trim().min(1, "API Key required"), + orgName: z.string().trim().min(1, "Organization name required") +}); + +export const AzureDevOpsConnectionApiTokenOutputCredentialsSchema = z.object({ + apiKey: 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.ApiToken) + .describe(AppConnections.CREATE(AppConnection.AzureDevOps).method), + credentials: AzureDevOpsConnectionApiTokenInputCredentialsSchema.describe( + AppConnections.CREATE(AppConnection.AzureDevOps).credentials + ) + }) +]); + +export const CreateAzureDevOpsConnectionSchema = ValidateAzureDevOpsConnectionCredentialsSchema.and( + GenericCreateAppConnectionFieldsSchema(AppConnection.AzureDevOps) +); + +export const UpdateAzureDevOpsConnectionSchema = z + .object({ + credentials: z + .union([AzureDevOpsConnectionOAuthInputCredentialsSchema, AzureDevOpsConnectionApiTokenInputCredentialsSchema]) + .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.ApiToken), + credentials: AzureDevOpsConnectionApiTokenOutputCredentialsSchema + }) + ]) +); + +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.ApiToken), + credentials: AzureDevOpsConnectionApiTokenOutputCredentialsSchema.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() +}); diff --git a/backend/src/services/app-connection/azure-devops/azure-devops-service.ts b/backend/src/services/app-connection/azure-devops/azure-devops-service.ts new file mode 100644 index 000000000..4a0705e10 --- /dev/null +++ b/backend/src/services/app-connection/azure-devops/azure-devops-service.ts @@ -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 { getAzureDevopsConnectionAccessToken } from "./azure-devops-fns"; +import { TAzureDevOpsConnection } from "./azure-devops-types"; + +type TGetAppConnectionFunc = ( + app: AppConnection, + connectionId: string, + actor: OrgServiceActor +) => Promise; + +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.ApiToken: + // For API 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, + kmsService: Pick +): Promise => { + const accessToken = await getAzureDevopsConnectionAccessToken(appConnection.id, appConnectionDAL, kmsService); + + // Both OAuth and API 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}/${orgName}/_apis/projects?api-version=7.1`; + try { + const { data } = await request.get(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, + kmsService: Pick +) => { + 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 + }; +}; diff --git a/backend/src/services/app-connection/azure-devops/azure-devops-types.ts b/backend/src/services/app-connection/azure-devops/azure-devops-types.ts new file mode 100644 index 000000000..7e8ab8025 --- /dev/null +++ b/backend/src/services/app-connection/azure-devops/azure-devops-types.ts @@ -0,0 +1,62 @@ +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; + +export type TAzureDevOpsConnectionInput = z.infer & { + app: AppConnection.AzureDevOps; +}; + +export type TValidateAzureDevOpsConnectionCredentialsSchema = typeof ValidateAzureDevOpsConnectionCredentialsSchema; + +export type TAzureDevOpsConnectionConfig = DiscriminativePick< + TAzureDevOpsConnectionInput, + "method" | "app" | "credentials" +> & { + orgId: string; +}; + +export type TAzureDevOpsConnectionCredentials = z.infer; + +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[]; +} + +export interface TAzureDevOps { + keyId: string; + displayName?: string; + startDateTime: string; + endDateTime: string; + secretText?: string; +} diff --git a/backend/src/services/secret-sync/azure-devops/azure-devops-sync-constants.ts b/backend/src/services/secret-sync/azure-devops/azure-devops-sync-constants.ts new file mode 100644 index 000000000..5cce286c4 --- /dev/null +++ b/backend/src/services/secret-sync/azure-devops/azure-devops-sync-constants.ts @@ -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 +}; diff --git a/backend/src/services/secret-sync/azure-devops/azure-devops-sync-fns.ts b/backend/src/services/secret-sync/azure-devops/azure-devops-sync-fns.ts new file mode 100644 index 000000000..84e99857a --- /dev/null +++ b/backend/src/services/secret-sync/azure-devops/azure-devops-sync-fns.ts @@ -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; + kmsService: Pick; +}; + +interface AzureDevOpsVariableGroup { + id: number; + name: string; + description: string; + type: string; + variables: Record; + 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(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 = {}; + 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(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 + }; +}; diff --git a/backend/src/services/secret-sync/azure-devops/azure-devops-sync-schemas.ts b/backend/src/services/secret-sync/azure-devops/azure-devops-sync-schemas.ts new file mode 100644 index 000000000..71c10ecba --- /dev/null +++ b/backend/src/services/secret-sync/azure-devops/azure-devops-sync-schemas.ts @@ -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) +}); diff --git a/backend/src/services/secret-sync/azure-devops/azure-devops-sync-types.ts b/backend/src/services/secret-sync/azure-devops/azure-devops-sync-types.ts new file mode 100644 index 000000000..005929d22 --- /dev/null +++ b/backend/src/services/secret-sync/azure-devops/azure-devops-sync-types.ts @@ -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; + +export type TAzureDevOpsSyncInput = z.infer; + +export type TAzureDevOpsSyncListItem = z.infer; + +export type TAzureDevOpsSyncDestinationConfig = z.infer; + +export type TAzureDevOpsSyncWithCredentials = TAzureDevOpsSync & { + connection: TAzureDevOpsConnection; +}; diff --git a/backend/src/services/secret-sync/azure-devops/index.ts b/backend/src/services/secret-sync/azure-devops/index.ts new file mode 100644 index 000000000..404757734 --- /dev/null +++ b/backend/src/services/secret-sync/azure-devops/index.ts @@ -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"; diff --git a/backend/src/services/secret-sync/secret-sync-enums.ts b/backend/src/services/secret-sync/secret-sync-enums.ts index 24f7d05f8..3cf940459 100644 --- a/backend/src/services/secret-sync/secret-sync-enums.ts +++ b/backend/src/services/secret-sync/secret-sync-enums.ts @@ -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", diff --git a/backend/src/services/secret-sync/secret-sync-fns.ts b/backend/src/services/secret-sync/secret-sync-fns.ts index dbf3a3699..d7c653613 100644 --- a/backend/src/services/secret-sync/secret-sync-fns.ts +++ b/backend/src/services/secret-sync/secret-sync-fns.ts @@ -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.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, diff --git a/backend/src/services/secret-sync/secret-sync-maps.ts b/backend/src/services/secret-sync/secret-sync-maps.ts index 832c15bf8..dd329734a 100644 --- a/backend/src/services/secret-sync/secret-sync-maps.ts +++ b/backend/src/services/secret-sync/secret-sync-maps.ts @@ -8,6 +8,7 @@ export const SECRET_SYNC_NAME_MAP: Record = { [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.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.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, diff --git a/backend/src/services/secret-sync/secret-sync-types.ts b/backend/src/services/secret-sync/secret-sync-types.ts index 22f7848ad..ff355e5e1 100644 --- a/backend/src/services/secret-sync/secret-sync-types.ts +++ b/backend/src/services/secret-sync/secret-sync-types.ts @@ -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 diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/AzureDevopsSyncFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/AzureDevopsSyncFields.tsx new file mode 100644 index 000000000..4cc41057f --- /dev/null +++ b/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/AzureDevopsSyncFields.tsx @@ -0,0 +1,76 @@ +import { Controller, useFormContext, useWatch } from "react-hook-form"; +import { SingleValue } from "react-select"; +import { faCircleInfo } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { SecretSyncConnectionField } from "@app/components/secret-syncs/forms/SecretSyncConnectionField"; +import { FilterableSelect, FormControl, Tooltip } from "@app/components/v2"; +import { useGetAzureDevOpsProjects } from "@app/hooks/api/appConnections/azure"; +import { AzureDevOpsProject } from "@app/hooks/api/appConnections/azure/types"; +import { SecretSync } from "@app/hooks/api/secretSyncs"; + +import { TSecretSyncForm } from "../schemas"; + +export const AzureDevOpsSyncFields = () => { + const { control, setValue } = useFormContext< + TSecretSyncForm & { destination: SecretSync.AzureDevOps } + >(); + + const connectionId = useWatch({ name: "connection.id", control }); + + const { data: { projects } = { projects: [] }, isLoading: isProjectsLoading } = + useGetAzureDevOpsProjects(connectionId, { + enabled: Boolean(connectionId) + }); + + return ( + <> + { + setValue("destinationConfig.devopsProjectId", ""); + }} + /> + + ( + +
+ Don't see the project you're looking for?{" "} + +
+ + } + > + v.appId === value) ?? null} + onChange={(option) => { + onChange((option as SingleValue)?.appId ?? null); + setValue( + "destinationConfig.devopsProjectName", + (option as SingleValue)?.name ?? "" + ); + }} + options={projects} + placeholder="Select a project..." + getOptionLabel={(option) => option.name} + getOptionValue={(option) => option.id} + /> +
+ )} + /> + + ); +}; diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/SecretSyncDestinationFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/SecretSyncDestinationFields.tsx index 48541b272..21866c4a6 100644 --- a/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/SecretSyncDestinationFields.tsx +++ b/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/SecretSyncDestinationFields.tsx @@ -7,6 +7,7 @@ import { OnePassSyncFields } from "./1PasswordSyncFields"; import { AwsParameterStoreSyncFields } from "./AwsParameterStoreSyncFields"; import { AwsSecretsManagerSyncFields } from "./AwsSecretsManagerSyncFields"; import { AzureAppConfigurationSyncFields } from "./AzureAppConfigurationSyncFields"; +import { AzureDevOpsSyncFields } from "./AzureDevopsSyncFields"; import { AzureKeyVaultSyncFields } from "./AzureKeyVaultSyncFields"; import { CamundaSyncFields } from "./CamundaSyncFields"; import { DatabricksSyncFields } from "./DatabricksSyncFields"; @@ -38,6 +39,8 @@ export const SecretSyncDestinationFields = () => { return ; case SecretSync.AzureAppConfiguration: return ; + case SecretSync.AzureDevOps: + return ; case SecretSync.Databricks: return ; case SecretSync.Humanitec: diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncOptionsFields/SecretSyncOptionsFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncOptionsFields/SecretSyncOptionsFields.tsx index 6050cab21..4b419bdcc 100644 --- a/frontend/src/components/secret-syncs/forms/SecretSyncOptionsFields/SecretSyncOptionsFields.tsx +++ b/frontend/src/components/secret-syncs/forms/SecretSyncOptionsFields/SecretSyncOptionsFields.tsx @@ -41,6 +41,7 @@ export const SecretSyncOptionsFields = ({ hideInitialSync }: Props) => { case SecretSync.GCPSecretManager: case SecretSync.AzureKeyVault: case SecretSync.AzureAppConfiguration: + case SecretSync.AzureDevOps: case SecretSync.Databricks: case SecretSync.Humanitec: case SecretSync.TerraformCloud: diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/AzureDevopsSyncReviewFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/AzureDevopsSyncReviewFields.tsx new file mode 100644 index 000000000..8b6cf3145 --- /dev/null +++ b/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/AzureDevopsSyncReviewFields.tsx @@ -0,0 +1,18 @@ +import { useFormContext } from "react-hook-form"; + +import { GenericFieldLabel } from "@app/components/secret-syncs"; +import { TSecretSyncForm } from "@app/components/secret-syncs/forms/schemas"; +import { SecretSync } from "@app/hooks/api/secretSyncs"; + +export const AzureDevOpsSyncReviewFields = () => { + const { watch } = useFormContext(); + const devopsProjectId = watch("destinationConfig.devopsProjectId"); + const devopsProjectName = watch("destinationConfig.devopsProjectName"); + + return ( + <> + {devopsProjectName} + {devopsProjectId} + + ); +}; diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/SecretSyncReviewFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/SecretSyncReviewFields.tsx index a53eec5e7..19988b7e1 100644 --- a/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/SecretSyncReviewFields.tsx +++ b/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/SecretSyncReviewFields.tsx @@ -16,6 +16,7 @@ import { AwsSecretsManagerSyncReviewFields } from "./AwsSecretsManagerSyncReviewFields"; import { AzureAppConfigurationSyncReviewFields } from "./AzureAppConfigurationSyncReviewFields"; +import { AzureDevOpsSyncReviewFields } from "./AzureDevopsSyncReviewFields"; import { AzureKeyVaultSyncReviewFields } from "./AzureKeyVaultSyncReviewFields"; import { CamundaSyncReviewFields } from "./CamundaSyncReviewFields"; import { DatabricksSyncReviewFields } from "./DatabricksSyncReviewFields"; @@ -70,6 +71,9 @@ export const SecretSyncReviewFields = () => { case SecretSync.AzureAppConfiguration: DestinationFieldsComponent = ; break; + case SecretSync.AzureDevOps: + DestinationFieldsComponent = ; + break; case SecretSync.Databricks: DestinationFieldsComponent = ; break; diff --git a/frontend/src/components/secret-syncs/forms/schemas/azure-devops-sync-destination-schema.ts b/frontend/src/components/secret-syncs/forms/schemas/azure-devops-sync-destination-schema.ts new file mode 100644 index 000000000..6aa63ab8f --- /dev/null +++ b/frontend/src/components/secret-syncs/forms/schemas/azure-devops-sync-destination-schema.ts @@ -0,0 +1,17 @@ +import { z } from "zod"; + +import { BaseSecretSyncSchema } from "@app/components/secret-syncs/forms/schemas/base-secret-sync-schema"; +import { SecretSync } from "@app/hooks/api/secretSyncs"; + +export const AzureDevOpsSyncDestinationSchema = BaseSecretSyncSchema().merge( + z.object({ + destination: z.literal(SecretSync.AzureDevOps), + destinationConfig: z.object({ + devopsProjectId: z.string().trim().min(1, { message: "Azure DevOps Project ID is required" }), + devopsProjectName: z + .string() + .trim() + .min(1, { message: "Azure DevOps Project Name is required" }) + }) + }) +); diff --git a/frontend/src/components/secret-syncs/forms/schemas/secret-sync-schema.ts b/frontend/src/components/secret-syncs/forms/schemas/secret-sync-schema.ts index 792226dae..5dfb643b4 100644 --- a/frontend/src/components/secret-syncs/forms/schemas/secret-sync-schema.ts +++ b/frontend/src/components/secret-syncs/forms/schemas/secret-sync-schema.ts @@ -4,6 +4,7 @@ import { OnePassSyncDestinationSchema } from "./1password-sync-destination-schem import { AwsParameterStoreSyncDestinationSchema } from "./aws-parameter-store-sync-destination-schema"; import { AwsSecretsManagerSyncDestinationSchema } from "./aws-secrets-manager-sync-destination-schema"; import { AzureAppConfigurationSyncDestinationSchema } from "./azure-app-configuration-sync-destination-schema"; +import { AzureDevOpsSyncDestinationSchema } from "./azure-devops-sync-destination-schema"; import { AzureKeyVaultSyncDestinationSchema } from "./azure-key-vault-sync-destination-schema"; import { CamundaSyncDestinationSchema } from "./camunda-sync-destination-schema"; import { DatabricksSyncDestinationSchema } from "./databricks-sync-destination-schema"; @@ -24,6 +25,7 @@ const SecretSyncUnionSchema = z.discriminatedUnion("destination", [ GcpSyncDestinationSchema, AzureKeyVaultSyncDestinationSchema, AzureAppConfigurationSyncDestinationSchema, + AzureDevOpsSyncDestinationSchema, DatabricksSyncDestinationSchema, HumanitecSyncDestinationSchema, TerraformCloudSyncDestinationSchema, diff --git a/frontend/src/helpers/appConnections.ts b/frontend/src/helpers/appConnections.ts index 54210438a..320c44bd6 100644 --- a/frontend/src/helpers/appConnections.ts +++ b/frontend/src/helpers/appConnections.ts @@ -15,6 +15,7 @@ import { AwsConnectionMethod, AzureAppConfigurationConnectionMethod, AzureClientSecretsConnectionMethod, + AzureDevOpsConnectionMethod, AzureKeyVaultConnectionMethod, CamundaConnectionMethod, DatabricksConnectionMethod, @@ -60,6 +61,7 @@ export const APP_CONNECTION_MAP: Record< name: "Azure Client Secrets", image: "Microsoft Azure.png" }, + [AppConnection.AzureDevOps]: { name: "Azure DevOps", image: "Microsoft Azure.png" }, [AppConnection.Databricks]: { name: "Databricks", image: "Databricks.png" }, [AppConnection.Humanitec]: { name: "Humanitec", image: "Humanitec.png" }, [AppConnection.TerraformCloud]: { name: "Terraform Cloud", image: "Terraform Cloud.png" }, @@ -85,6 +87,7 @@ export const getAppConnectionMethodDetails = (method: TAppConnection["method"]) case AzureKeyVaultConnectionMethod.OAuth: case AzureAppConfigurationConnectionMethod.OAuth: case AzureClientSecretsConnectionMethod.OAuth: + case AzureDevOpsConnectionMethod.OAuth: case GitHubConnectionMethod.OAuth: return { name: "OAuth", icon: faPassport }; case AwsConnectionMethod.AccessKey: @@ -102,6 +105,7 @@ export const getAppConnectionMethodDetails = (method: TAppConnection["method"]) case TerraformCloudConnectionMethod.ApiToken: case VercelConnectionMethod.ApiToken: case OnePassConnectionMethod.ApiToken: + case AzureDevOpsConnectionMethod.ApiToken: return { name: "API Token", icon: faKey }; case PostgresConnectionMethod.UsernameAndPassword: case MsSqlConnectionMethod.UsernameAndPassword: diff --git a/frontend/src/helpers/secretSyncs.ts b/frontend/src/helpers/secretSyncs.ts index 88a0f7517..ba4ba3ae6 100644 --- a/frontend/src/helpers/secretSyncs.ts +++ b/frontend/src/helpers/secretSyncs.ts @@ -16,6 +16,10 @@ export const SECRET_SYNC_MAP: Record = { [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, diff --git a/frontend/src/hooks/api/appConnections/azure/queries.tsx b/frontend/src/hooks/api/appConnections/azure/queries.tsx index 98d1c2d29..8123806fd 100644 --- a/frontend/src/hooks/api/appConnections/azure/queries.tsx +++ b/frontend/src/hooks/api/appConnections/azure/queries.tsx @@ -3,12 +3,14 @@ import { useQuery, UseQueryOptions } from "@tanstack/react-query"; import { apiRequest } from "@app/config/request"; import { appConnectionKeys } from "../queries"; -import { TAzureClient } from "./types"; +import { AzureDevOpsProjectsResponse, TAzureClient } from "./types"; const azureConnectionKeys = { all: [...appConnectionKeys.all, "azure"] as const, listClients: (connectionId: string) => - [...azureConnectionKeys.all, "clients", connectionId] as const + [...azureConnectionKeys.all, "clients", connectionId] as const, + listDevopsProjects: (connectionId: string) => + [...azureConnectionKeys.all, "devops-projects", connectionId] as const }; export const useAzureConnectionListClients = ( @@ -35,3 +37,29 @@ export const useAzureConnectionListClients = ( ...options }); }; + +export const fetchAzureDevOpsProjects = async ( + connectionId: string +): Promise => { + if (!connectionId) { + throw new Error("Connection ID is required"); + } + + const { data } = await apiRequest.get( + `/api/v1/app-connections/azure-devops/${connectionId}/projects` + ); + + return data; +}; + +export const useGetAzureDevOpsProjects = ( + connectionId: string, + options?: Omit, "queryKey" | "queryFn"> +) => { + return useQuery({ + queryKey: azureConnectionKeys.listDevopsProjects(connectionId), + queryFn: () => fetchAzureDevOpsProjects(connectionId), + enabled: Boolean(connectionId), + ...options + }); +}; diff --git a/frontend/src/hooks/api/appConnections/azure/types.ts b/frontend/src/hooks/api/appConnections/azure/types.ts index 29da637f0..a5d21f047 100644 --- a/frontend/src/hooks/api/appConnections/azure/types.ts +++ b/frontend/src/hooks/api/appConnections/azure/types.ts @@ -3,3 +3,13 @@ export type TAzureClient = { appId: string; id: string; }; + +export interface AzureDevOpsProject { + id: string; + name: string; + appId: string; +} + +export interface AzureDevOpsProjectsResponse { + projects: AzureDevOpsProject[]; +} diff --git a/frontend/src/hooks/api/appConnections/enums.ts b/frontend/src/hooks/api/appConnections/enums.ts index 6ed986453..6438cc5be 100644 --- a/frontend/src/hooks/api/appConnections/enums.ts +++ b/frontend/src/hooks/api/appConnections/enums.ts @@ -6,6 +6,7 @@ export enum AppConnection { AzureKeyVault = "azure-key-vault", AzureAppConfiguration = "azure-app-configuration", AzureClientSecrets = "azure-client-secrets", + AzureDevOps = "azure-devops", Databricks = "databricks", Humanitec = "humanitec", TerraformCloud = "terraform-cloud", diff --git a/frontend/src/hooks/api/appConnections/types/app-options.ts b/frontend/src/hooks/api/appConnections/types/app-options.ts index 9725e1a9c..1dc5d852a 100644 --- a/frontend/src/hooks/api/appConnections/types/app-options.ts +++ b/frontend/src/hooks/api/appConnections/types/app-options.ts @@ -45,6 +45,11 @@ export type TDatabricksConnectionOption = TAppConnectionOptionBase & { app: AppConnection.Databricks; }; +export type TAzureDevOpsConnectionOption = TAppConnectionOptionBase & { + app: AppConnection.AzureDevOps; + oauthClientId?: string; +}; + export type THumanitecConnectionOption = TAppConnectionOptionBase & { app: AppConnection.Humanitec; }; @@ -108,6 +113,7 @@ export type TAppConnectionOption = | TAzureAppConfigurationConnectionOption | TAzureKeyVaultConnectionOption | TAzureClientSecretsConnectionOption + | TAzureDevOpsConnectionOption | TDatabricksConnectionOption | THumanitecConnectionOption | TTerraformCloudConnectionOption @@ -131,6 +137,7 @@ export type TAppConnectionOptionMap = { [AppConnection.AzureKeyVault]: TAzureKeyVaultConnectionOption; [AppConnection.AzureAppConfiguration]: TAzureAppConfigurationConnectionOption; [AppConnection.AzureClientSecrets]: TAzureClientSecretsConnectionOption; + [AppConnection.AzureDevOps]: TAzureDevOpsConnectionOption; [AppConnection.Databricks]: TDatabricksConnectionOption; [AppConnection.Humanitec]: THumanitecConnectionOption; [AppConnection.TerraformCloud]: TTerraformCloudConnectionOption; diff --git a/frontend/src/hooks/api/appConnections/types/azure-devops-connection.ts b/frontend/src/hooks/api/appConnections/types/azure-devops-connection.ts new file mode 100644 index 000000000..1c6da31f8 --- /dev/null +++ b/frontend/src/hooks/api/appConnections/types/azure-devops-connection.ts @@ -0,0 +1,27 @@ +import { AppConnection } from "@app/hooks/api/appConnections/enums"; +import { TRootAppConnection } from "@app/hooks/api/appConnections/types/root-connection"; + +export enum AzureDevOpsConnectionMethod { + OAuth = "oauth", + ApiToken = "api-token" +} + +export type TAzureDevOpsConnection = TRootAppConnection & { + app: AppConnection.AzureDevOps; +} & ( + | { + method: AzureDevOpsConnectionMethod.OAuth; + credentials: { + code: string; + tenantId: string; + orgName: string; + }; + } + | { + method: AzureDevOpsConnectionMethod.ApiToken; + credentials: { + apiKey: string; + orgName: string; + }; + } + ); diff --git a/frontend/src/hooks/api/appConnections/types/index.ts b/frontend/src/hooks/api/appConnections/types/index.ts index e88a59bf1..f60c213a7 100644 --- a/frontend/src/hooks/api/appConnections/types/index.ts +++ b/frontend/src/hooks/api/appConnections/types/index.ts @@ -5,6 +5,7 @@ import { TAuth0Connection } from "./auth0-connection"; import { TAwsConnection } from "./aws-connection"; import { TAzureAppConfigurationConnection } from "./azure-app-configuration-connection"; import { TAzureClientSecretsConnection } from "./azure-client-secrets-connection"; +import { TAzureDevOpsConnection } from "./azure-devops-connection"; import { TAzureKeyVaultConnection } from "./azure-key-vault-connection"; import { TCamundaConnection } from "./camunda-connection"; import { TDatabricksConnection } from "./databricks-connection"; @@ -28,6 +29,7 @@ export * from "./auth0-connection"; export * from "./aws-connection"; export * from "./azure-app-configuration-connection"; export * from "./azure-client-secrets-connection"; +export * from "./azure-devops-connection"; export * from "./azure-key-vault-connection"; export * from "./camunda-connection"; export * from "./databricks-connection"; @@ -54,6 +56,7 @@ export type TAppConnection = | TAzureKeyVaultConnection | TAzureAppConfigurationConnection | TAzureClientSecretsConnection + | TAzureDevOpsConnection | TDatabricksConnection | THumanitecConnection | TTerraformCloudConnection @@ -103,6 +106,7 @@ export type TAppConnectionMap = { [AppConnection.AzureKeyVault]: TAzureKeyVaultConnection; [AppConnection.AzureAppConfiguration]: TAzureAppConfigurationConnection; [AppConnection.AzureClientSecrets]: TAzureClientSecretsConnection; + [AppConnection.AzureDevOps]: TAzureDevOpsConnection; [AppConnection.Databricks]: TDatabricksConnection; [AppConnection.Humanitec]: THumanitecConnection; [AppConnection.TerraformCloud]: TTerraformCloudConnection; diff --git a/frontend/src/hooks/api/secretSyncs/enums.ts b/frontend/src/hooks/api/secretSyncs/enums.ts index 7185563d5..f381b3fca 100644 --- a/frontend/src/hooks/api/secretSyncs/enums.ts +++ b/frontend/src/hooks/api/secretSyncs/enums.ts @@ -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", diff --git a/frontend/src/hooks/api/secretSyncs/types/azure-devops-sync.ts b/frontend/src/hooks/api/secretSyncs/types/azure-devops-sync.ts new file mode 100644 index 000000000..3567c507b --- /dev/null +++ b/frontend/src/hooks/api/secretSyncs/types/azure-devops-sync.ts @@ -0,0 +1,16 @@ +import { AppConnection } from "@app/hooks/api/appConnections/enums"; +import { SecretSync } from "@app/hooks/api/secretSyncs"; +import { TRootSecretSync } from "@app/hooks/api/secretSyncs/types/root-sync"; + +export type TAzureDevOpsSync = TRootSecretSync & { + destination: SecretSync.AzureDevOps; + destinationConfig: { + devopsProjectId: string; + devopsProjectName: string; + }; + connection: { + app: AppConnection.AzureDevOps; + name: string; + id: string; + }; +}; diff --git a/frontend/src/hooks/api/secretSyncs/types/index.ts b/frontend/src/hooks/api/secretSyncs/types/index.ts index f28a0820b..4f447f9df 100644 --- a/frontend/src/hooks/api/secretSyncs/types/index.ts +++ b/frontend/src/hooks/api/secretSyncs/types/index.ts @@ -5,6 +5,7 @@ import { TOnePassSync } from "./1password-sync"; import { TAwsParameterStoreSync } from "./aws-parameter-store-sync"; import { TAwsSecretsManagerSync } from "./aws-secrets-manager-sync"; import { TAzureAppConfigurationSync } from "./azure-app-configuration-sync"; +import { TAzureDevOpsSync } from "./azure-devops-sync"; import { TAzureKeyVaultSync } from "./azure-key-vault-sync"; import { TCamundaSync } from "./camunda-sync"; import { TDatabricksSync } from "./databricks-sync"; @@ -32,6 +33,7 @@ export type TSecretSync = | TGcpSync | TAzureKeyVaultSync | TAzureAppConfigurationSync + | TAzureDevOpsSync | TDatabricksSync | THumanitecSync | TTerraformCloudSync diff --git a/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/AppConnectionForm.tsx b/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/AppConnectionForm.tsx index 999d4b884..a5075e033 100644 --- a/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/AppConnectionForm.tsx +++ b/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/AppConnectionForm.tsx @@ -14,6 +14,7 @@ import { Auth0ConnectionForm } from "./Auth0ConnectionForm"; import { AwsConnectionForm } from "./AwsConnectionForm"; import { AzureAppConfigurationConnectionForm } from "./AzureAppConfigurationConnectionForm"; import { AzureClientSecretsConnectionForm } from "./AzureClientSecretsConnectionForm"; +import { AzureDevOpsConnectionForm } from "./AzureDevOpsConnectionForm"; import { AzureKeyVaultConnectionForm } from "./AzureKeyVaultConnectionForm"; import { CamundaConnectionForm } from "./CamundaConnectionForm"; import { DatabricksConnectionForm } from "./DatabricksConnectionForm"; @@ -99,6 +100,8 @@ const CreateForm = ({ app, onComplete }: CreateFormProps) => { return ; case AppConnection.AzureClientSecrets: return ; + case AppConnection.AzureDevOps: + return ; case AppConnection.Windmill: return ; case AppConnection.Auth0: @@ -179,6 +182,8 @@ const UpdateForm = ({ appConnection, onComplete }: UpdateFormProps) => { return ; case AppConnection.AzureClientSecrets: return ; + case AppConnection.AzureDevOps: + return ; case AppConnection.Windmill: return ; case AppConnection.Auth0: diff --git a/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/AzureDevOpsConnectionForm.tsx b/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/AzureDevOpsConnectionForm.tsx new file mode 100644 index 000000000..6c5358f87 --- /dev/null +++ b/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/AzureDevOpsConnectionForm.tsx @@ -0,0 +1,291 @@ +/* eslint-disable no-case-declarations */ +import crypto from "crypto"; + +import { useState } from "react"; +import { Controller, FormProvider, useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; + +import { Button, FormControl, Input, ModalClose, Select, SelectItem } from "@app/components/v2"; +import { APP_CONNECTION_MAP, getAppConnectionMethodDetails } from "@app/helpers/appConnections"; +import { isInfisicalCloud } from "@app/helpers/platform"; +import { + AzureDevOpsConnectionMethod, + TAzureDevOpsConnection, + useGetAppConnectionOption +} from "@app/hooks/api/appConnections"; +import { AppConnection } from "@app/hooks/api/appConnections/enums"; + +import { + genericAppConnectionFieldsSchema, + GenericAppConnectionsFields +} from "./GenericAppConnectionFields"; + +type ApiTokenForm = z.infer; + +type Props = { + appConnection?: TAzureDevOpsConnection; + onSubmit: (formData: ApiTokenForm) => Promise; +}; + +// Base schema with common fields +const baseSchema = genericAppConnectionFieldsSchema.extend({ + app: z.literal(AppConnection.AzureDevOps), + method: z.nativeEnum(AzureDevOpsConnectionMethod) +}); + +// Method-specific schemas +const oauthSchema = baseSchema.extend({ + method: z.literal(AzureDevOpsConnectionMethod.OAuth), + tenantId: z.string().trim().min(1, "Tenant ID is required"), + orgName: z.string().trim().min(1, "Organization name is required") +}); + +const apiTokenSchema = baseSchema.extend({ + method: z.literal(AzureDevOpsConnectionMethod.ApiToken), + credentials: z.object({ + apiKey: z.string().trim().min(1, "API Key is required"), + orgName: z.string().trim().min(1, "Organization name is required") + }) +}); + +// Union schema +const formSchema = z.discriminatedUnion("method", [oauthSchema, apiTokenSchema]); + +type FormData = z.infer; + +const getDefaultValues = (appConnection?: TAzureDevOpsConnection): Partial => { + if (!appConnection) { + return { + app: AppConnection.AzureDevOps, + method: AzureDevOpsConnectionMethod.OAuth + }; + } + + const base = { + name: appConnection.name, + description: appConnection.description, + app: appConnection.app, + method: appConnection.method + }; + const { credentials } = appConnection; + + switch (appConnection.method) { + case AzureDevOpsConnectionMethod.OAuth: + if ("tenantId" in credentials && "orgName" in credentials) { + return { + ...base, + method: AzureDevOpsConnectionMethod.OAuth, + tenantId: credentials.tenantId, + orgName: credentials.orgName + }; + } + break; + case AzureDevOpsConnectionMethod.ApiToken: + if ("apiKey" in credentials && "orgName" in credentials) { + return { + ...base, + method: AzureDevOpsConnectionMethod.ApiToken, + credentials: { + apiKey: credentials.apiKey, + orgName: credentials.orgName + } + }; + } + break; + default: + return base; + } + + return base; +}; + +export const AzureDevOpsConnectionForm = ({ appConnection, onSubmit }: Props) => { + const isUpdate = Boolean(appConnection); + const [isRedirecting, setIsRedirecting] = useState(false); + + const { + option: { oauthClientId }, + isLoading + } = useGetAppConnectionOption(AppConnection.AzureDevOps); + + const form = useForm({ + resolver: zodResolver(formSchema), + defaultValues: getDefaultValues(appConnection) + }); + + const { + handleSubmit, + control, + watch, + formState: { isSubmitting, isDirty } + } = form; + + const selectedMethod = watch("method"); + + const onSubmitHandler = async (formData: FormData) => { + switch (formData.method) { + case AzureDevOpsConnectionMethod.OAuth: + setIsRedirecting(true); + const state = crypto.randomBytes(16).toString("hex"); + localStorage.setItem("latestCSRFToken", state); + localStorage.setItem( + "azureDevOpsConnectionFormData", + JSON.stringify({ ...formData, connectionId: appConnection?.id }) + ); + + window.location.assign( + `https://login.microsoftonline.com/${formData.tenantId || "common"}/oauth2/v2.0/authorize?client_id=${oauthClientId}&response_type=code&redirect_uri=${window.location.origin}/organization/app-connections/azure/oauth/callback&response_mode=query&scope=https://azconfig.io/.default%20openid%20offline_access&state=${state}<:>azure-devops` + ); + break; + + case AzureDevOpsConnectionMethod.ApiToken: + onSubmit(formData); + break; + + default: + throw new Error(`Unhandled Azure Connection method: ${(formData as FormData).method}`); + } + }; + + const isMissingConfig = selectedMethod === AzureDevOpsConnectionMethod.OAuth && !oauthClientId; + const methodDetails = getAppConnectionMethodDetails(selectedMethod); + + return ( + +
+ {!isUpdate && } + + ( + + + + )} + /> + + {/* OAuth-specific fields */} + {selectedMethod === AzureDevOpsConnectionMethod.OAuth && ( + <> + ( + + + + )} + /> + ( + + + + )} + /> + + )} + + {/* API Token-specific fields */} + {selectedMethod === AzureDevOpsConnectionMethod.ApiToken && ( + <> + ( + + + + )} + /> + ( + + + + )} + /> + + )} + +
+ + + + +
+ +
+ ); +}; diff --git a/frontend/src/pages/organization/AppConnections/OauthCallbackPage/OauthCallbackPage.tsx b/frontend/src/pages/organization/AppConnections/OauthCallbackPage/OauthCallbackPage.tsx index 67028448d..f8829fd64 100644 --- a/frontend/src/pages/organization/AppConnections/OauthCallbackPage/OauthCallbackPage.tsx +++ b/frontend/src/pages/organization/AppConnections/OauthCallbackPage/OauthCallbackPage.tsx @@ -8,10 +8,12 @@ import { APP_CONNECTION_MAP } from "@app/helpers/appConnections"; import { AzureAppConfigurationConnectionMethod, AzureClientSecretsConnectionMethod, + AzureDevOpsConnectionMethod, AzureKeyVaultConnectionMethod, GitHubConnectionMethod, TAzureAppConfigurationConnection, TAzureClientSecretsConnection, + TAzureDevOpsConnection, TAzureKeyVaultConnection, TGitHubConnection, TGitHubRadarConnection, @@ -42,6 +44,19 @@ type AzureClientSecretsFormData = BaseFormData & Pick & Pick; +type OAuthCredentials = Extract< + TAzureDevOpsConnection, + { method: AzureDevOpsConnectionMethod.OAuth } +>["credentials"]; +type ApiTokenCredentials = Extract< + TAzureDevOpsConnection, + { method: AzureDevOpsConnectionMethod.ApiToken } +>["credentials"]; + +type AzureDevOpsFormData = BaseFormData & + Pick & + (Pick | Pick); + type FormDataMap = { [AppConnection.GitHub]: GithubFormData & { app: AppConnection.GitHub }; [AppConnection.GitHubRadar]: GithubRadarFormData & { app: AppConnection.GitHubRadar }; @@ -52,6 +67,9 @@ type FormDataMap = { [AppConnection.AzureClientSecrets]: AzureClientSecretsFormData & { app: AppConnection.AzureClientSecrets; }; + [AppConnection.AzureDevOps]: AzureDevOpsFormData & { + app: AppConnection.AzureDevOps; + }; }; const formDataStorageFieldMap: Partial> = { @@ -59,7 +77,8 @@ const formDataStorageFieldMap: Partial> = { [AppConnection.GitHubRadar]: "githubRadarConnectionFormData", [AppConnection.AzureKeyVault]: "azureKeyVaultConnectionFormData", [AppConnection.AzureAppConfiguration]: "azureAppConfigurationConnectionFormData", - [AppConnection.AzureClientSecrets]: "azureClientSecretsConnectionFormData" + [AppConnection.AzureClientSecrets]: "azureClientSecretsConnectionFormData", + [AppConnection.AzureDevOps]: "azureDevOpsConnectionFormData" }; export const OAuthCallbackPage = () => { @@ -258,6 +277,60 @@ export const OAuthCallbackPage = () => { }; }, []); + const handleAzureDevOps = useCallback(async () => { + const formData = getFormData(AppConnection.AzureDevOps); + if (formData === null) return null; + + clearState(AppConnection.AzureDevOps); + + const { connectionId, name, description, returnUrl } = formData; + + try { + if (!("tenantId" in formData)) { + throw new Error("Expected OAuth form data but got API token data"); + } + + if (connectionId) { + await updateAppConnection.mutateAsync({ + app: AppConnection.AzureDevOps, + connectionId, + credentials: { + code: code as string, + tenantId: formData.tenantId as string, + orgName: formData.orgName + } + }); + } else { + await createAppConnection.mutateAsync({ + app: AppConnection.AzureDevOps, + name, + description, + method: AzureDevOpsConnectionMethod.OAuth, + credentials: { + code: code as string, + tenantId: formData.tenantId as string, + orgName: formData.orgName + } + }); + } + } catch (err: any) { + createNotification({ + title: `Failed to ${connectionId ? "update" : "add"} Azure DevOps Connection`, + text: err?.message, + type: "error" + }); + navigate({ + to: returnUrl ?? "/organization/app-connections" + }); + } + + return { + connectionId, + returnUrl, + appConnectionName: formData.app + }; + }, []); + const handleGithub = useCallback(async () => { const formData = getFormData(AppConnection.GitHub); if (formData === null) return null; @@ -396,6 +469,8 @@ export const OAuthCallbackPage = () => { data = await handleAzureAppConfiguration(); } else if (appConnection === AppConnection.AzureClientSecrets) { data = await handleAzureClientSecrets(); + } else if (appConnection === AppConnection.AzureDevOps) { + data = await handleAzureDevOps(); } if (data) { diff --git a/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/SecretSyncDestinationCol/AzureDevopsDestinationCol.tsx b/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/SecretSyncDestinationCol/AzureDevopsDestinationCol.tsx new file mode 100644 index 000000000..147c2e7f5 --- /dev/null +++ b/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/SecretSyncDestinationCol/AzureDevopsDestinationCol.tsx @@ -0,0 +1,14 @@ +import { TAzureDevOpsSync } from "@app/hooks/api/secretSyncs/types/azure-devops-sync"; + +import { getSecretSyncDestinationColValues } from "../helpers"; +import { SecretSyncTableCell } from "../SecretSyncTableCell"; + +type Props = { + secretSync: TAzureDevOpsSync; +}; + +export const AzureDevOpsSyncDestinationCol = ({ secretSync }: Props) => { + const { primaryText, secondaryText } = getSecretSyncDestinationColValues(secretSync); + + return ; +}; diff --git a/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/SecretSyncDestinationCol/SecretSyncDestinationCol.tsx b/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/SecretSyncDestinationCol/SecretSyncDestinationCol.tsx index cd5ff2cf7..041d67e73 100644 --- a/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/SecretSyncDestinationCol/SecretSyncDestinationCol.tsx +++ b/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/SecretSyncDestinationCol/SecretSyncDestinationCol.tsx @@ -4,6 +4,7 @@ import { OnePassSyncDestinationCol } from "./1PasswordSyncDestinationCol"; import { AwsParameterStoreSyncDestinationCol } from "./AwsParameterStoreSyncDestinationCol"; import { AwsSecretsManagerSyncDestinationCol } from "./AwsSecretsManagerSyncDestinationCol"; import { AzureAppConfigurationDestinationSyncCol } from "./AzureAppConfigurationDestinationSyncCol"; +import { AzureDevOpsSyncDestinationCol } from "./AzureDevopsDestinationCol"; import { AzureKeyVaultDestinationSyncCol } from "./AzureKeyVaultDestinationSyncCol"; import { CamundaSyncDestinationCol } from "./CamundaSyncDestinationCol"; import { DatabricksSyncDestinationCol } from "./DatabricksSyncDestinationCol"; @@ -55,6 +56,8 @@ export const SecretSyncDestinationCol = ({ secretSync }: Props) => { return ; case SecretSync.OnePass: return ; + case SecretSync.AzureDevOps: + return ; default: throw new Error( `Unhandled Secret Sync Destination Col: ${(secretSync as TSecretSync).destination}` diff --git a/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/helpers/index.ts b/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/helpers/index.ts index 4943df471..3ed01a7bb 100644 --- a/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/helpers/index.ts +++ b/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/helpers/index.ts @@ -110,6 +110,9 @@ export const getSecretSyncDestinationColValues = (secretSync: TSecretSync) => { primaryText = destinationConfig.vaultId; secondaryText = "Vault ID"; break; + case SecretSync.AzureDevOps: + primaryText = destinationConfig.devopsProjectName; + break; default: throw new Error(`Unhandled Destination Col Values ${destination}`); } diff --git a/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncDestinationSection/AzureDevopsSyncDestinationSection.tsx b/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncDestinationSection/AzureDevopsSyncDestinationSection.tsx new file mode 100644 index 000000000..608b443f1 --- /dev/null +++ b/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncDestinationSection/AzureDevopsSyncDestinationSection.tsx @@ -0,0 +1,14 @@ +import { GenericFieldLabel } from "@app/components/secret-syncs"; +import { TAzureDevOpsSync } from "@app/hooks/api/secretSyncs/types/azure-devops-sync"; + +type Props = { + secretSync: TAzureDevOpsSync; +}; + +export const AzureDevOpsSyncDestinationSection = ({ secretSync }: Props) => { + const { + destinationConfig: { devopsProjectName } + } = secretSync; + + return {devopsProjectName}; +}; diff --git a/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncDestinationSection/SecretSyncDestinatonSection.tsx b/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncDestinationSection/SecretSyncDestinatonSection.tsx index f443c6106..3fdbfd5e9 100644 --- a/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncDestinationSection/SecretSyncDestinatonSection.tsx +++ b/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncDestinationSection/SecretSyncDestinatonSection.tsx @@ -14,6 +14,7 @@ import { OnePassSyncDestinationSection } from "./1PasswordSyncDestinationSection import { AwsParameterStoreSyncDestinationSection } from "./AwsParameterStoreSyncDestinationSection"; import { AwsSecretsManagerSyncDestinationSection } from "./AwsSecretsManagerSyncDestinationSection"; import { AzureAppConfigurationSyncDestinationSection } from "./AzureAppConfigurationSyncDestinationSection"; +import { AzureDevOpsSyncDestinationSection } from "./AzureDevopsSyncDestinationSection"; import { AzureKeyVaultSyncDestinationSection } from "./AzureKeyVaultSyncDestinationSection"; import { CamundaSyncDestinationSection } from "./CamundaSyncDestinationSection"; import { DatabricksSyncDestinationSection } from "./DatabricksSyncDestinationSection"; @@ -89,6 +90,9 @@ export const SecretSyncDestinationSection = ({ secretSync, onEditDestination }: case SecretSync.OnePass: DestinationComponents = ; break; + case SecretSync.AzureDevOps: + DestinationComponents = ; + break; default: throw new Error(`Unhandled Destination Section components: ${destination}`); } diff --git a/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncOptionsSection/SecretSyncOptionsSection.tsx b/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncOptionsSection/SecretSyncOptionsSection.tsx index fbd66b9f5..e02d2ff84 100644 --- a/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncOptionsSection/SecretSyncOptionsSection.tsx +++ b/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncOptionsSection/SecretSyncOptionsSection.tsx @@ -41,6 +41,7 @@ export const SecretSyncOptionsSection = ({ secretSync, onEditOptions }: Props) = case SecretSync.GCPSecretManager: case SecretSync.AzureKeyVault: case SecretSync.AzureAppConfiguration: + case SecretSync.AzureDevOps: case SecretSync.Databricks: case SecretSync.Humanitec: case SecretSync.TerraformCloud: