mirror of
https://github.com/awatertrevi/infisical.git
synced 2026-09-22 13:39:35 +00:00
feat(secret-sync): Add Azure Devops Secret Sync
This commit is contained in:
@@ -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."
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -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<AppConnection, (server:
|
||||
[AppConnection.AzureKeyVault]: registerAzureKeyVaultConnectionRouter,
|
||||
[AppConnection.AzureAppConfiguration]: registerAzureAppConfigurationConnectionRouter,
|
||||
[AppConnection.AzureClientSecrets]: registerAzureClientSecretsConnectionRouter,
|
||||
[AppConnection.AzureDevOps]: registerAzureDevOpsConnectionRouter,
|
||||
[AppConnection.Databricks]: registerDatabricksConnectionRouter,
|
||||
[AppConnection.Humanitec]: registerHumanitecConnectionRouter,
|
||||
[AppConnection.TerraformCloud]: registerTerraformCloudConnectionRouter,
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import {
|
||||
AzureDevOpsSyncSchema,
|
||||
CreateAzureDevOpsSyncSchema,
|
||||
UpdateAzureDevOpsSyncSchema
|
||||
} from "@app/services/secret-sync/azure-devops";
|
||||
import { SecretSync } from "@app/services/secret-sync/secret-sync-enums";
|
||||
|
||||
import { registerSyncSecretsEndpoints } from "./secret-sync-endpoints";
|
||||
|
||||
export const registerAzureDevOpsSyncRouter = async (server: FastifyZodProvider) =>
|
||||
registerSyncSecretsEndpoints({
|
||||
destination: SecretSync.AzureDevOps,
|
||||
server,
|
||||
responseSchema: AzureDevOpsSyncSchema,
|
||||
createSchema: CreateAzureDevOpsSyncSchema,
|
||||
updateSchema: UpdateAzureDevOpsSyncSchema
|
||||
});
|
||||
@@ -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<SecretSync, (server: Fastif
|
||||
[SecretSync.GCPSecretManager]: registerGcpSyncRouter,
|
||||
[SecretSync.AzureKeyVault]: registerAzureKeyVaultSyncRouter,
|
||||
[SecretSync.AzureAppConfiguration]: registerAzureAppConfigurationSyncRouter,
|
||||
[SecretSync.AzureDevOps]: registerAzureDevOpsSyncRouter,
|
||||
[SecretSync.Databricks]: registerDatabricksSyncRouter,
|
||||
[SecretSync.Humanitec]: registerHumanitecSyncRouter,
|
||||
[SecretSync.TerraformCloud]: registerTerraformCloudSyncRouter,
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
AzureAppConfigurationSyncListItemSchema,
|
||||
AzureAppConfigurationSyncSchema
|
||||
} from "@app/services/secret-sync/azure-app-configuration";
|
||||
import { AzureDevOpsSyncListItemSchema, AzureDevOpsSyncSchema } from "@app/services/secret-sync/azure-devops";
|
||||
import { AzureKeyVaultSyncListItemSchema, AzureKeyVaultSyncSchema } from "@app/services/secret-sync/azure-key-vault";
|
||||
import { CamundaSyncListItemSchema, CamundaSyncSchema } from "@app/services/secret-sync/camunda";
|
||||
import { DatabricksSyncListItemSchema, DatabricksSyncSchema } from "@app/services/secret-sync/databricks";
|
||||
@@ -38,6 +39,7 @@ const SecretSyncSchema = z.discriminatedUnion("destination", [
|
||||
GcpSyncSchema,
|
||||
AzureKeyVaultSyncSchema,
|
||||
AzureAppConfigurationSyncSchema,
|
||||
AzureDevOpsSyncSchema,
|
||||
DatabricksSyncSchema,
|
||||
HumanitecSyncSchema,
|
||||
TerraformCloudSyncSchema,
|
||||
@@ -57,6 +59,7 @@ const SecretSyncOptionsSchema = z.discriminatedUnion("destination", [
|
||||
GcpSyncListItemSchema,
|
||||
AzureKeyVaultSyncListItemSchema,
|
||||
AzureAppConfigurationSyncListItemSchema,
|
||||
AzureDevOpsSyncListItemSchema,
|
||||
DatabricksSyncListItemSchema,
|
||||
HumanitecSyncListItemSchema,
|
||||
TerraformCloudSyncListItemSchema,
|
||||
|
||||
@@ -7,6 +7,7 @@ export enum AppConnection {
|
||||
AzureKeyVault = "azure-key-vault",
|
||||
AzureAppConfiguration = "azure-app-configuration",
|
||||
AzureClientSecrets = "azure-client-secrets",
|
||||
AzureDevOps = "azure-devops",
|
||||
Humanitec = "humanitec",
|
||||
TerraformCloud = "terraform-cloud",
|
||||
Vercel = "vercel",
|
||||
|
||||
@@ -39,6 +39,11 @@ import {
|
||||
getAzureClientSecretsConnectionListItem,
|
||||
validateAzureClientSecretsConnectionCredentials
|
||||
} from "./azure-client-secrets";
|
||||
import { AzureDevOpsConnectionMethod } from "./azure-devops/azure-devops-enums";
|
||||
import {
|
||||
getAzureDevopsConnectionListItem,
|
||||
validateAzureDevOpsConnectionCredentials
|
||||
} from "./azure-devops/azure-devops-fns";
|
||||
import {
|
||||
AzureKeyVaultConnectionMethod,
|
||||
getAzureKeyVaultConnectionListItem,
|
||||
@@ -98,6 +103,7 @@ export const listAppConnectionOptions = () => {
|
||||
getGcpConnectionListItem(),
|
||||
getAzureKeyVaultConnectionListItem(),
|
||||
getAzureAppConfigurationConnectionListItem(),
|
||||
getAzureDevopsConnectionListItem(),
|
||||
getDatabricksConnectionListItem(),
|
||||
getHumanitecConnectionListItem(),
|
||||
getTerraformCloudConnectionListItem(),
|
||||
@@ -173,6 +179,7 @@ export const validateAppConnectionCredentials = async (
|
||||
validateAzureAppConfigurationConnectionCredentials as TAppConnectionCredentialsValidator,
|
||||
[AppConnection.AzureClientSecrets]:
|
||||
validateAzureClientSecretsConnectionCredentials as TAppConnectionCredentialsValidator,
|
||||
[AppConnection.AzureDevOps]: validateAzureDevOpsConnectionCredentials as TAppConnectionCredentialsValidator,
|
||||
[AppConnection.Humanitec]: validateHumanitecConnectionCredentials as TAppConnectionCredentialsValidator,
|
||||
[AppConnection.Postgres]: validateSqlConnectionCredentials as TAppConnectionCredentialsValidator,
|
||||
[AppConnection.MsSql]: validateSqlConnectionCredentials as TAppConnectionCredentialsValidator,
|
||||
@@ -201,6 +208,7 @@ export const getAppConnectionMethodName = (method: TAppConnection["method"]) =>
|
||||
case AzureAppConfigurationConnectionMethod.OAuth:
|
||||
case AzureClientSecretsConnectionMethod.OAuth:
|
||||
case GitHubConnectionMethod.OAuth:
|
||||
case AzureDevOpsConnectionMethod.OAuth:
|
||||
return "OAuth";
|
||||
case AwsConnectionMethod.AccessKey:
|
||||
case OCIConnectionMethod.AccessKey:
|
||||
@@ -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,
|
||||
|
||||
@@ -8,6 +8,7 @@ export const APP_CONNECTION_NAME_MAP: Record<AppConnection, string> = {
|
||||
[AppConnection.AzureKeyVault]: "Azure Key Vault",
|
||||
[AppConnection.AzureAppConfiguration]: "Azure App Configuration",
|
||||
[AppConnection.AzureClientSecrets]: "Azure Client Secrets",
|
||||
[AppConnection.AzureDevOps]: "Azure DevOps",
|
||||
[AppConnection.Databricks]: "Databricks",
|
||||
[AppConnection.Humanitec]: "Humanitec",
|
||||
[AppConnection.TerraformCloud]: "Terraform Cloud",
|
||||
@@ -33,6 +34,7 @@ export const APP_CONNECTION_PLAN_MAP: Record<AppConnection, AppConnectionPlanTyp
|
||||
[AppConnection.AzureKeyVault]: AppConnectionPlanType.Regular,
|
||||
[AppConnection.AzureAppConfiguration]: AppConnectionPlanType.Regular,
|
||||
[AppConnection.AzureClientSecrets]: AppConnectionPlanType.Regular,
|
||||
[AppConnection.AzureDevOps]: AppConnectionPlanType.Regular,
|
||||
[AppConnection.Databricks]: AppConnectionPlanType.Regular,
|
||||
[AppConnection.Humanitec]: AppConnectionPlanType.Regular,
|
||||
[AppConnection.TerraformCloud]: AppConnectionPlanType.Regular,
|
||||
|
||||
@@ -41,6 +41,8 @@ import { awsConnectionService } from "./aws/aws-connection-service";
|
||||
import { ValidateAzureAppConfigurationConnectionCredentialsSchema } from "./azure-app-configuration";
|
||||
import { ValidateAzureClientSecretsConnectionCredentialsSchema } from "./azure-client-secrets";
|
||||
import { azureClientSecretsConnectionService } from "./azure-client-secrets/azure-client-secrets-service";
|
||||
import { ValidateAzureDevOpsConnectionCredentialsSchema } from "./azure-devops/azure-devops-schemas";
|
||||
import { azureDevOpsConnectionService } from "./azure-devops/azure-devops-service";
|
||||
import { ValidateAzureKeyVaultConnectionCredentialsSchema } from "./azure-key-vault";
|
||||
import { ValidateCamundaConnectionCredentialsSchema } from "./camunda";
|
||||
import { camundaConnectionService } from "./camunda/camunda-connection-service";
|
||||
@@ -84,6 +86,7 @@ const VALIDATE_APP_CONNECTION_CREDENTIALS_MAP: Record<AppConnection, TValidateAp
|
||||
[AppConnection.GCP]: ValidateGcpConnectionCredentialsSchema,
|
||||
[AppConnection.AzureKeyVault]: ValidateAzureKeyVaultConnectionCredentialsSchema,
|
||||
[AppConnection.AzureAppConfiguration]: ValidateAzureAppConfigurationConnectionCredentialsSchema,
|
||||
[AppConnection.AzureDevOps]: ValidateAzureDevOpsConnectionCredentialsSchema,
|
||||
[AppConnection.Databricks]: ValidateDatabricksConnectionCredentialsSchema,
|
||||
[AppConnection.Humanitec]: ValidateHumanitecConnectionCredentialsSchema,
|
||||
[AppConnection.TerraformCloud]: ValidateTerraformCloudConnectionCredentialsSchema,
|
||||
@@ -498,6 +501,7 @@ export const appConnectionServiceFactory = ({
|
||||
camunda: camundaConnectionService(connectAppConnectionById, appConnectionDAL, kmsService),
|
||||
vercel: vercelConnectionService(connectAppConnectionById),
|
||||
azureClientSecrets: azureClientSecretsConnectionService(connectAppConnectionById, appConnectionDAL, kmsService),
|
||||
azureDevOps: azureDevOpsConnectionService(connectAppConnectionById, appConnectionDAL, kmsService),
|
||||
auth0: auth0ConnectionService(connectAppConnectionById, appConnectionDAL, kmsService),
|
||||
hcvault: hcVaultConnectionService(connectAppConnectionById),
|
||||
windmill: windmillConnectionService(connectAppConnectionById),
|
||||
|
||||
@@ -39,6 +39,12 @@ import {
|
||||
TAzureClientSecretsConnectionInput,
|
||||
TValidateAzureClientSecretsConnectionCredentialsSchema
|
||||
} from "./azure-client-secrets";
|
||||
import {
|
||||
TAzureDevOpsConnection,
|
||||
TAzureDevOpsConnectionConfig,
|
||||
TAzureDevOpsConnectionInput,
|
||||
TValidateAzureDevOpsConnectionCredentialsSchema
|
||||
} from "./azure-devops/azure-devops-types";
|
||||
import {
|
||||
TAzureKeyVaultConnection,
|
||||
TAzureKeyVaultConnectionConfig,
|
||||
@@ -132,6 +138,7 @@ export type TAppConnection = { id: string } & (
|
||||
| TGcpConnection
|
||||
| TAzureKeyVaultConnection
|
||||
| TAzureAppConfigurationConnection
|
||||
| TAzureDevOpsConnection
|
||||
| TDatabricksConnection
|
||||
| THumanitecConnection
|
||||
| TTerraformCloudConnection
|
||||
@@ -161,6 +168,7 @@ export type TAppConnectionInput = { id: string } & (
|
||||
| TGcpConnectionInput
|
||||
| TAzureKeyVaultConnectionInput
|
||||
| TAzureAppConfigurationConnectionInput
|
||||
| TAzureDevOpsConnectionInput
|
||||
| TDatabricksConnectionInput
|
||||
| THumanitecConnectionInput
|
||||
| TTerraformCloudConnectionInput
|
||||
@@ -197,6 +205,7 @@ export type TAppConnectionConfig =
|
||||
| TGcpConnectionConfig
|
||||
| TAzureKeyVaultConnectionConfig
|
||||
| TAzureAppConfigurationConnectionConfig
|
||||
| TAzureDevOpsConnectionConfig
|
||||
| TAzureClientSecretsConnectionConfig
|
||||
| TDatabricksConnectionConfig
|
||||
| THumanitecConnectionConfig
|
||||
@@ -220,6 +229,7 @@ export type TValidateAppConnectionCredentialsSchema =
|
||||
| TValidateAzureKeyVaultConnectionCredentialsSchema
|
||||
| TValidateAzureAppConfigurationConnectionCredentialsSchema
|
||||
| TValidateAzureClientSecretsConnectionCredentialsSchema
|
||||
| TValidateAzureDevOpsConnectionCredentialsSchema
|
||||
| TValidateDatabricksConnectionCredentialsSchema
|
||||
| TValidateHumanitecConnectionCredentialsSchema
|
||||
| TValidatePostgresConnectionCredentialsSchema
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
export enum AzureDevOpsConnectionMethod {
|
||||
OAuth = "oauth",
|
||||
ApiToken = "api-token"
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
/* eslint-disable no-case-declarations */
|
||||
import { AxiosError, AxiosResponse } from "axios";
|
||||
|
||||
import { getConfig } from "@app/lib/config/env";
|
||||
import { request } from "@app/lib/config/request";
|
||||
import { BadRequestError, InternalServerError, NotFoundError } from "@app/lib/errors";
|
||||
import { logger } from "@app/lib/logger";
|
||||
import {
|
||||
decryptAppConnectionCredentials,
|
||||
encryptAppConnectionCredentials,
|
||||
getAppConnectionMethodName
|
||||
} from "@app/services/app-connection/app-connection-fns";
|
||||
import { IntegrationUrls } from "@app/services/integration-auth/integration-list";
|
||||
import { TKmsServiceFactory } from "@app/services/kms/kms-service";
|
||||
|
||||
import { TAppConnectionDALFactory } from "../app-connection-dal";
|
||||
import { AppConnection } from "../app-connection-enums";
|
||||
import { AzureDevOpsConnectionMethod } from "./azure-devops-enums";
|
||||
import {
|
||||
ExchangeCodeAzureResponse,
|
||||
TAzureDevOpsConnectionConfig,
|
||||
TAzureDevOpsConnectionCredentials
|
||||
} from "./azure-devops-types";
|
||||
|
||||
export const getAzureDevopsConnectionListItem = () => {
|
||||
const { INF_APP_CONNECTION_AZURE_CLIENT_ID } = getConfig();
|
||||
|
||||
return {
|
||||
name: "Azure DevOps" as const,
|
||||
app: AppConnection.AzureDevOps as const,
|
||||
methods: Object.values(AzureDevOpsConnectionMethod) as [
|
||||
AzureDevOpsConnectionMethod.OAuth,
|
||||
AzureDevOpsConnectionMethod.ApiToken
|
||||
],
|
||||
oauthClientId: INF_APP_CONNECTION_AZURE_CLIENT_ID
|
||||
};
|
||||
};
|
||||
|
||||
export const getAzureDevopsConnectionAccessToken = async (
|
||||
connectionId: string,
|
||||
appConnectionDAL: Pick<TAppConnectionDALFactory, "findById" | "updateById">,
|
||||
kmsService: Pick<TKmsServiceFactory, "createCipherPairWithDataKey">
|
||||
) => {
|
||||
const appConnection = await appConnectionDAL.findById(connectionId);
|
||||
|
||||
if (!appConnection) {
|
||||
throw new NotFoundError({ message: `Connection with ID '${connectionId}' not found` });
|
||||
}
|
||||
|
||||
if (appConnection.app !== AppConnection.AzureDevOps) {
|
||||
throw new BadRequestError({
|
||||
message: `Connection with ID '${connectionId}' is not an Azure DevOps connection`
|
||||
});
|
||||
}
|
||||
|
||||
const credentials = (await decryptAppConnectionCredentials({
|
||||
orgId: appConnection.orgId,
|
||||
kmsService,
|
||||
encryptedCredentials: appConnection.encryptedCredentials
|
||||
})) as TAzureDevOpsConnectionCredentials;
|
||||
|
||||
// Handle different connection methods
|
||||
switch (appConnection.method) {
|
||||
case AzureDevOpsConnectionMethod.OAuth:
|
||||
const appCfg = getConfig();
|
||||
if (!appCfg.INF_APP_CONNECTION_AZURE_CLIENT_ID || !appCfg.INF_APP_CONNECTION_AZURE_CLIENT_SECRET) {
|
||||
throw new BadRequestError({
|
||||
message: `Azure environment variables have not been configured`
|
||||
});
|
||||
}
|
||||
|
||||
if (!("refreshToken" in credentials)) {
|
||||
throw new BadRequestError({ message: "Invalid OAuth credentials" });
|
||||
}
|
||||
|
||||
const { refreshToken, tenantId } = credentials;
|
||||
const currentTime = Date.now();
|
||||
|
||||
const { data } = await request.post<ExchangeCodeAzureResponse>(
|
||||
IntegrationUrls.AZURE_TOKEN_URL.replace("common", tenantId || "common"),
|
||||
new URLSearchParams({
|
||||
grant_type: "refresh_token",
|
||||
scope: `https://app.vssps.visualstudio.com/.default`,
|
||||
client_id: appCfg.INF_APP_CONNECTION_AZURE_CLIENT_ID,
|
||||
client_secret: appCfg.INF_APP_CONNECTION_AZURE_CLIENT_SECRET,
|
||||
refresh_token: refreshToken
|
||||
})
|
||||
);
|
||||
|
||||
const updatedCredentials = {
|
||||
...credentials,
|
||||
accessToken: data.access_token,
|
||||
expiresAt: currentTime + data.expires_in * 1000,
|
||||
refreshToken: data.refresh_token
|
||||
};
|
||||
|
||||
const encryptedCredentials = await encryptAppConnectionCredentials({
|
||||
credentials: updatedCredentials,
|
||||
orgId: appConnection.orgId,
|
||||
kmsService
|
||||
});
|
||||
|
||||
await appConnectionDAL.updateById(appConnection.id, { encryptedCredentials });
|
||||
|
||||
return data.access_token;
|
||||
|
||||
case AzureDevOpsConnectionMethod.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<ExchangeCodeAzureResponse> | null = null;
|
||||
let tokenError: AxiosError | null = null;
|
||||
|
||||
try {
|
||||
const oauthCredentials = inputCredentials as { code: string; tenantId: string };
|
||||
tokenResp = await request.post<ExchangeCodeAzureResponse>(
|
||||
IntegrationUrls.AZURE_TOKEN_URL.replace("common", oauthCredentials.tenantId || "common"),
|
||||
new URLSearchParams({
|
||||
grant_type: "authorization_code",
|
||||
code: oauthCredentials.code,
|
||||
scope: `https://app.vssps.visualstudio.com/.default`,
|
||||
client_id: INF_APP_CONNECTION_AZURE_CLIENT_ID,
|
||||
client_secret: INF_APP_CONNECTION_AZURE_CLIENT_SECRET,
|
||||
redirect_uri: `${SITE_URL}/organization/app-connections/azure/oauth/callback`
|
||||
})
|
||||
);
|
||||
} catch (e: unknown) {
|
||||
if (e instanceof AxiosError) {
|
||||
tokenError = e;
|
||||
} else {
|
||||
throw new BadRequestError({
|
||||
message: `Unable to validate connection: verify credentials`
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (tokenError) {
|
||||
if (tokenError instanceof AxiosError) {
|
||||
throw new BadRequestError({
|
||||
message: `Failed to get access token: ${
|
||||
(tokenError?.response?.data as { error_description?: string })?.error_description || "Unknown error"
|
||||
}`
|
||||
});
|
||||
} else {
|
||||
throw new InternalServerError({
|
||||
message: "Failed to get access token"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (!tokenResp) {
|
||||
throw new InternalServerError({
|
||||
message: `Failed to get access token: Token was empty with no error`
|
||||
});
|
||||
}
|
||||
|
||||
const oauthCredentials = inputCredentials as { code: string; tenantId: string; orgName: string };
|
||||
return {
|
||||
tenantId: oauthCredentials.tenantId,
|
||||
orgName: oauthCredentials.orgName,
|
||||
accessToken: tokenResp.data.access_token,
|
||||
refreshToken: tokenResp.data.refresh_token,
|
||||
expiresAt: Date.now() + tokenResp.data.expires_in * 1000
|
||||
};
|
||||
|
||||
case AzureDevOpsConnectionMethod.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}`
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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()
|
||||
});
|
||||
@@ -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<TAzureDevOpsConnection>;
|
||||
|
||||
type TAzureDevOpsProject = {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
url?: string;
|
||||
state?: string;
|
||||
visibility?: string;
|
||||
lastUpdateTime?: string;
|
||||
revision?: number;
|
||||
abbreviation?: string;
|
||||
defaultTeamImageUrl?: string;
|
||||
};
|
||||
|
||||
type TAzureDevOpsProjectsResponse = {
|
||||
count: number;
|
||||
value: TAzureDevOpsProject[];
|
||||
};
|
||||
|
||||
const getAuthHeaders = (appConnection: TAzureDevOpsConnection, accessToken: string) => {
|
||||
switch (appConnection.method) {
|
||||
case AzureDevOpsConnectionMethod.OAuth:
|
||||
return {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
Accept: "application/json"
|
||||
};
|
||||
case AzureDevOpsConnectionMethod.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<TAppConnectionDALFactory, "findById" | "update" | "updateById">,
|
||||
kmsService: Pick<TKmsServiceFactory, "createCipherPairWithDataKey">
|
||||
): Promise<TAzureDevOpsProject[]> => {
|
||||
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<TAzureDevOpsProjectsResponse>(devOpsEndpoint, {
|
||||
headers: getAuthHeaders(appConnection, accessToken)
|
||||
});
|
||||
|
||||
return data.value || [];
|
||||
} catch (error) {
|
||||
if (error instanceof AxiosError) {
|
||||
// Provide more specific error messages based on the response
|
||||
if (error?.response?.status === 401) {
|
||||
throw new Error(
|
||||
`Authentication failed for Azure DevOps organization: ${orgName}. Please check your credentials and ensure the token has the required scopes (vso.project or vso.profile).`
|
||||
);
|
||||
} else if (error?.response?.status === 403) {
|
||||
throw new Error(
|
||||
`Access denied to Azure DevOps organization: ${orgName}. Please ensure the user has access to the organization.`
|
||||
);
|
||||
} else if (error?.response?.status === 404) {
|
||||
throw new Error(`Azure DevOps organization not found: ${orgName}. Please verify the organization name.`);
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const azureDevOpsConnectionService = (
|
||||
getAppConnection: TGetAppConnectionFunc,
|
||||
appConnectionDAL: Pick<TAppConnectionDALFactory, "findById" | "update" | "updateById">,
|
||||
kmsService: Pick<TKmsServiceFactory, "createCipherPairWithDataKey">
|
||||
) => {
|
||||
const listProjects = async (connectionId: string, actor: OrgServiceActor) => {
|
||||
const appConnection = await getAppConnection(AppConnection.AzureDevOps, connectionId, actor);
|
||||
|
||||
const projects = await listAzureDevOpsProjects(appConnection, appConnectionDAL, kmsService);
|
||||
|
||||
return projects.map((project) => ({
|
||||
id: project.id,
|
||||
name: project.name,
|
||||
appId: project.id,
|
||||
description: project.description,
|
||||
url: project.url,
|
||||
state: project.state,
|
||||
visibility: project.visibility,
|
||||
lastUpdateTime: project.lastUpdateTime,
|
||||
revision: project.revision,
|
||||
abbreviation: project.abbreviation,
|
||||
defaultTeamImageUrl: project.defaultTeamImageUrl
|
||||
}));
|
||||
};
|
||||
|
||||
return {
|
||||
listProjects
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,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<typeof AzureDevOpsConnectionSchema>;
|
||||
|
||||
export type TAzureDevOpsConnectionInput = z.infer<typeof CreateAzureDevOpsConnectionSchema> & {
|
||||
app: AppConnection.AzureDevOps;
|
||||
};
|
||||
|
||||
export type TValidateAzureDevOpsConnectionCredentialsSchema = typeof ValidateAzureDevOpsConnectionCredentialsSchema;
|
||||
|
||||
export type TAzureDevOpsConnectionConfig = DiscriminativePick<
|
||||
TAzureDevOpsConnectionInput,
|
||||
"method" | "app" | "credentials"
|
||||
> & {
|
||||
orgId: string;
|
||||
};
|
||||
|
||||
export type TAzureDevOpsConnectionCredentials = z.infer<typeof AzureDevOpsConnectionOAuthOutputCredentialsSchema>;
|
||||
|
||||
export interface ExchangeCodeAzureResponse {
|
||||
token_type: string;
|
||||
scope: string;
|
||||
expires_in: number;
|
||||
ext_expires_in: number;
|
||||
access_token: string;
|
||||
refresh_token: string;
|
||||
id_token: string;
|
||||
}
|
||||
|
||||
export interface TAzureRegisteredApp {
|
||||
id: string;
|
||||
appId: string;
|
||||
displayName: string;
|
||||
description?: string;
|
||||
createdDateTime: string;
|
||||
identifierUris?: string[];
|
||||
signInAudience?: string;
|
||||
}
|
||||
|
||||
export interface TAzureListRegisteredAppsResponse {
|
||||
"@odata.context": string;
|
||||
"@odata.nextLink"?: string;
|
||||
value: TAzureRegisteredApp[];
|
||||
}
|
||||
|
||||
export interface TAzureDevOps {
|
||||
keyId: string;
|
||||
displayName?: string;
|
||||
startDateTime: string;
|
||||
endDateTime: string;
|
||||
secretText?: string;
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
4
backend/src/services/secret-sync/azure-devops/index.ts
Normal file
4
backend/src/services/secret-sync/azure-devops/index.ts
Normal file
@@ -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
|
||||
|
||||
@@ -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 (
|
||||
<>
|
||||
<SecretSyncConnectionField
|
||||
onChange={() => {
|
||||
setValue("destinationConfig.devopsProjectId", "");
|
||||
}}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
name="destinationConfig.devopsProjectId"
|
||||
control={control}
|
||||
render={({ field: { value, onChange }, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
label="Project"
|
||||
helperText={
|
||||
<Tooltip
|
||||
className="max-w-md"
|
||||
content="Ensure the project exists in the connection's Azure DevOps instance URL."
|
||||
>
|
||||
<div>
|
||||
<span>Don't see the project you're looking for?</span>{" "}
|
||||
<FontAwesomeIcon icon={faCircleInfo} className="text-mineshaft-400" />
|
||||
</div>
|
||||
</Tooltip>
|
||||
}
|
||||
>
|
||||
<FilterableSelect
|
||||
menuPlacement="top"
|
||||
isLoading={isProjectsLoading && Boolean(connectionId)}
|
||||
isDisabled={!connectionId}
|
||||
value={projects?.find((v) => v.appId === value) ?? null}
|
||||
onChange={(option) => {
|
||||
onChange((option as SingleValue<AzureDevOpsProject>)?.appId ?? null);
|
||||
setValue(
|
||||
"destinationConfig.devopsProjectName",
|
||||
(option as SingleValue<AzureDevOpsProject>)?.name ?? ""
|
||||
);
|
||||
}}
|
||||
options={projects}
|
||||
placeholder="Select a project..."
|
||||
getOptionLabel={(option) => option.name}
|
||||
getOptionValue={(option) => option.id}
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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 <AzureKeyVaultSyncFields />;
|
||||
case SecretSync.AzureAppConfiguration:
|
||||
return <AzureAppConfigurationSyncFields />;
|
||||
case SecretSync.AzureDevOps:
|
||||
return <AzureDevOpsSyncFields />;
|
||||
case SecretSync.Databricks:
|
||||
return <DatabricksSyncFields />;
|
||||
case SecretSync.Humanitec:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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<TSecretSyncForm & { destination: SecretSync.AzureDevOps }>();
|
||||
const devopsProjectId = watch("destinationConfig.devopsProjectId");
|
||||
const devopsProjectName = watch("destinationConfig.devopsProjectName");
|
||||
|
||||
return (
|
||||
<>
|
||||
<GenericFieldLabel label="Project">{devopsProjectName}</GenericFieldLabel>
|
||||
<GenericFieldLabel label="Project ID">{devopsProjectId}</GenericFieldLabel>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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 = <AzureAppConfigurationSyncReviewFields />;
|
||||
break;
|
||||
case SecretSync.AzureDevOps:
|
||||
DestinationFieldsComponent = <AzureDevOpsSyncReviewFields />;
|
||||
break;
|
||||
case SecretSync.Databricks:
|
||||
DestinationFieldsComponent = <DatabricksSyncReviewFields />;
|
||||
break;
|
||||
|
||||
@@ -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" })
|
||||
})
|
||||
})
|
||||
);
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -16,6 +16,10 @@ export const SECRET_SYNC_MAP: Record<SecretSync, { name: string; image: string }
|
||||
name: "Azure App Configuration",
|
||||
image: "Microsoft Azure.png"
|
||||
},
|
||||
[SecretSync.AzureDevOps]: {
|
||||
name: "Azure DevOps",
|
||||
image: "Microsoft Azure.png"
|
||||
},
|
||||
[SecretSync.Databricks]: {
|
||||
name: "Databricks",
|
||||
image: "Databricks.png"
|
||||
@@ -65,6 +69,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,
|
||||
|
||||
@@ -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<AzureDevOpsProjectsResponse> => {
|
||||
if (!connectionId) {
|
||||
throw new Error("Connection ID is required");
|
||||
}
|
||||
|
||||
const { data } = await apiRequest.get<AzureDevOpsProjectsResponse>(
|
||||
`/api/v1/app-connections/azure-devops/${connectionId}/projects`
|
||||
);
|
||||
|
||||
return data;
|
||||
};
|
||||
|
||||
export const useGetAzureDevOpsProjects = (
|
||||
connectionId: string,
|
||||
options?: Omit<UseQueryOptions<AzureDevOpsProjectsResponse, Error>, "queryKey" | "queryFn">
|
||||
) => {
|
||||
return useQuery({
|
||||
queryKey: azureConnectionKeys.listDevopsProjects(connectionId),
|
||||
queryFn: () => fetchAzureDevOpsProjects(connectionId),
|
||||
enabled: Boolean(connectionId),
|
||||
...options
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
);
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
};
|
||||
@@ -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
|
||||
|
||||
@@ -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 <CamundaConnectionForm onSubmit={onSubmit} />;
|
||||
case AppConnection.AzureClientSecrets:
|
||||
return <AzureClientSecretsConnectionForm />;
|
||||
case AppConnection.AzureDevOps:
|
||||
return <AzureDevOpsConnectionForm onSubmit={onSubmit} />;
|
||||
case AppConnection.Windmill:
|
||||
return <WindmillConnectionForm onSubmit={onSubmit} />;
|
||||
case AppConnection.Auth0:
|
||||
@@ -179,6 +182,8 @@ const UpdateForm = ({ appConnection, onComplete }: UpdateFormProps) => {
|
||||
return <CamundaConnectionForm onSubmit={onSubmit} appConnection={appConnection} />;
|
||||
case AppConnection.AzureClientSecrets:
|
||||
return <AzureClientSecretsConnectionForm appConnection={appConnection} />;
|
||||
case AppConnection.AzureDevOps:
|
||||
return <AzureDevOpsConnectionForm appConnection={appConnection} onSubmit={onSubmit} />;
|
||||
case AppConnection.Windmill:
|
||||
return <WindmillConnectionForm onSubmit={onSubmit} appConnection={appConnection} />;
|
||||
case AppConnection.Auth0:
|
||||
|
||||
@@ -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<typeof apiTokenSchema>;
|
||||
|
||||
type Props = {
|
||||
appConnection?: TAzureDevOpsConnection;
|
||||
onSubmit: (formData: ApiTokenForm) => Promise<void>;
|
||||
};
|
||||
|
||||
// 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<typeof formSchema>;
|
||||
|
||||
const getDefaultValues = (appConnection?: TAzureDevOpsConnection): Partial<FormData> => {
|
||||
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<FormData>({
|
||||
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 (
|
||||
<FormProvider {...form}>
|
||||
<form onSubmit={handleSubmit(onSubmitHandler)} className="space-y-6">
|
||||
{!isUpdate && <GenericAppConnectionsFields />}
|
||||
|
||||
<Controller
|
||||
name="method"
|
||||
control={control}
|
||||
render={({ field: { value, onChange }, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
tooltipText={`The method you would like to use to connect with ${
|
||||
APP_CONNECTION_MAP[AppConnection.AzureDevOps].name
|
||||
}. This field cannot be changed after creation.`}
|
||||
errorText={
|
||||
!isLoading && isMissingConfig
|
||||
? `Environment variables have not been configured. ${
|
||||
isInfisicalCloud()
|
||||
? "Please contact Infisical."
|
||||
: `See documentation to configure Azure ${methodDetails.name} Connections.`
|
||||
}`
|
||||
: error?.message
|
||||
}
|
||||
isError={Boolean(error?.message) || isMissingConfig}
|
||||
label="Method"
|
||||
>
|
||||
<Select
|
||||
isDisabled={isUpdate}
|
||||
value={value}
|
||||
onValueChange={(val) => onChange(val)}
|
||||
className="w-full border border-mineshaft-500"
|
||||
position="popper"
|
||||
dropdownContainerClassName="max-w-none"
|
||||
>
|
||||
{Object.values(AzureDevOpsConnectionMethod).map((method) => {
|
||||
return (
|
||||
<SelectItem value={method} key={method}>
|
||||
{getAppConnectionMethodDetails(method).name}
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</Select>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* OAuth-specific fields */}
|
||||
{selectedMethod === AzureDevOpsConnectionMethod.OAuth && (
|
||||
<>
|
||||
<Controller
|
||||
name="tenantId"
|
||||
control={control}
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
tooltipText="The Directory (tenant) ID."
|
||||
isError={Boolean(error?.message)}
|
||||
label="Tenant ID"
|
||||
errorText={error?.message}
|
||||
>
|
||||
<Input {...field} placeholder="e4f34ea5-ad23-4291-8585-66d20d603cc8" />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="orgName"
|
||||
control={control}
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
tooltipText="Your Azure DevOps organization name."
|
||||
isError={Boolean(error?.message)}
|
||||
label="Organization Name"
|
||||
errorText={error?.message}
|
||||
>
|
||||
<Input {...field} placeholder="myorganization" />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* API Token-specific fields */}
|
||||
{selectedMethod === AzureDevOpsConnectionMethod.ApiToken && (
|
||||
<>
|
||||
<Controller
|
||||
name="credentials.apiKey"
|
||||
control={control}
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
tooltipText="Personal Access Token from Azure DevOps."
|
||||
isError={Boolean(error?.message)}
|
||||
label="API Key"
|
||||
errorText={error?.message}
|
||||
>
|
||||
<Input
|
||||
{...field}
|
||||
type="password"
|
||||
placeholder="Enter your Personal Access Token"
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="credentials.orgName"
|
||||
control={control}
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
tooltipText="Your Azure DevOps organization name."
|
||||
isError={Boolean(error?.message)}
|
||||
label="Organization Name"
|
||||
errorText={error?.message}
|
||||
>
|
||||
<Input {...field} placeholder="myorganization" />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="mt-8 flex items-center">
|
||||
<Button
|
||||
className="mr-4"
|
||||
size="sm"
|
||||
type="submit"
|
||||
colorSchema="secondary"
|
||||
isLoading={isSubmitting || isRedirecting}
|
||||
isDisabled={isSubmitting || (!isUpdate && !isDirty) || isMissingConfig || isRedirecting}
|
||||
>
|
||||
{isUpdate ? "Reconnect to Azure" : "Connect to Azure"}
|
||||
</Button>
|
||||
<ModalClose asChild>
|
||||
<Button colorSchema="secondary" variant="plain">
|
||||
Cancel
|
||||
</Button>
|
||||
</ModalClose>
|
||||
</div>
|
||||
</form>
|
||||
</FormProvider>
|
||||
);
|
||||
};
|
||||
@@ -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<TAzureClientSecretsConnection, "name" | "method" | "description"> &
|
||||
Pick<TAzureClientSecretsConnection["credentials"], "tenantId">;
|
||||
|
||||
type OAuthCredentials = Extract<
|
||||
TAzureDevOpsConnection,
|
||||
{ method: AzureDevOpsConnectionMethod.OAuth }
|
||||
>["credentials"];
|
||||
type ApiTokenCredentials = Extract<
|
||||
TAzureDevOpsConnection,
|
||||
{ method: AzureDevOpsConnectionMethod.ApiToken }
|
||||
>["credentials"];
|
||||
|
||||
type AzureDevOpsFormData = BaseFormData &
|
||||
Pick<TAzureDevOpsConnection, "name" | "method" | "description"> &
|
||||
(Pick<OAuthCredentials, "tenantId" | "orgName"> | Pick<ApiTokenCredentials, "orgName">);
|
||||
|
||||
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<Record<AppConnection, string>> = {
|
||||
@@ -59,7 +77,8 @@ const formDataStorageFieldMap: Partial<Record<AppConnection, string>> = {
|
||||
[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) {
|
||||
|
||||
@@ -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 <SecretSyncTableCell primaryText={primaryText} secondaryText={secondaryText} />;
|
||||
};
|
||||
@@ -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 <OCIVaultSyncDestinationCol secretSync={secretSync} />;
|
||||
case SecretSync.OnePass:
|
||||
return <OnePassSyncDestinationCol secretSync={secretSync} />;
|
||||
case SecretSync.AzureDevOps:
|
||||
return <AzureDevOpsSyncDestinationCol secretSync={secretSync} />;
|
||||
default:
|
||||
throw new Error(
|
||||
`Unhandled Secret Sync Destination Col: ${(secretSync as TSecretSync).destination}`
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
|
||||
@@ -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 <GenericFieldLabel label="Project">{devopsProjectName}</GenericFieldLabel>;
|
||||
};
|
||||
@@ -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 = <OnePassSyncDestinationSection secretSync={secretSync} />;
|
||||
break;
|
||||
case SecretSync.AzureDevOps:
|
||||
DestinationComponents = <AzureDevOpsSyncDestinationSection secretSync={secretSync} />;
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unhandled Destination Section components: ${destination}`);
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user