diff --git a/.env.example b/.env.example index 1926bcef63..d3ec397dea 100644 --- a/.env.example +++ b/.env.example @@ -47,11 +47,13 @@ CLIENT_ID_VERCEL= CLIENT_ID_NETLIFY= CLIENT_ID_GITHUB= CLIENT_ID_GITLAB= +CLIENT_ID_BITBUCKET= CLIENT_SECRET_HEROKU= CLIENT_SECRET_VERCEL= CLIENT_SECRET_NETLIFY= CLIENT_SECRET_GITHUB= CLIENT_SECRET_GITLAB= +CLIENT_SECRET_BITBUCKET= CLIENT_SLUG_VERCEL= # Sentry (optional) for monitoring errors diff --git a/backend/src/config/index.ts b/backend/src/config/index.ts index bcbb715ade..655081f842 100644 --- a/backend/src/config/index.ts +++ b/backend/src/config/index.ts @@ -37,6 +37,7 @@ export const getClientIdNetlify = async () => (await client.getSecret("CLIENT_ID export const getClientIdGitHub = async () => (await client.getSecret("CLIENT_ID_GITHUB")).secretValue; export const getClientIdGitLab = async () => (await client.getSecret("CLIENT_ID_GITLAB")).secretValue; export const getClientIdGoogle = async () => (await client.getSecret("CLIENT_ID_GOOGLE")).secretValue; +export const getClientIdBitBucket = async () => (await client.getSecret("CLIENT_ID_BITBUCKET")).secretValue; export const getClientSecretAzure = async () => (await client.getSecret("CLIENT_SECRET_AZURE")).secretValue; export const getClientSecretHeroku = async () => (await client.getSecret("CLIENT_SECRET_HEROKU")).secretValue; export const getClientSecretVercel = async () => (await client.getSecret("CLIENT_SECRET_VERCEL")).secretValue; @@ -44,6 +45,7 @@ export const getClientSecretNetlify = async () => (await client.getSecret("CLIEN export const getClientSecretGitHub = async () => (await client.getSecret("CLIENT_SECRET_GITHUB")).secretValue; export const getClientSecretGitLab = async () => (await client.getSecret("CLIENT_SECRET_GITLAB")).secretValue; export const getClientSecretGoogle = async () => (await client.getSecret("CLIENT_SECRET_GOOGLE")).secretValue; +export const getClientSecretBitBucket = async () => (await client.getSecret("CLIENT_SECRET_BITBUCKET")).secretValue; export const getClientSlugVercel = async () => (await client.getSecret("CLIENT_SLUG_VERCEL")).secretValue; export const getPostHogHost = async () => (await client.getSecret("POSTHOG_HOST")).secretValue || "https://app.posthog.com"; export const getPostHogProjectApiKey = async () => (await client.getSecret("POSTHOG_PROJECT_API_KEY")).secretValue || "phc_nSin8j5q2zdhpFDI1ETmFNUIuTG4DwKVyIigrY10XiE"; diff --git a/backend/src/controllers/v1/integrationAuthController.ts b/backend/src/controllers/v1/integrationAuthController.ts index f625f10a2d..88968a2727 100644 --- a/backend/src/controllers/v1/integrationAuthController.ts +++ b/backend/src/controllers/v1/integrationAuthController.ts @@ -7,6 +7,7 @@ import { IntegrationService } from "../../services"; import { ALGORITHM_AES_256_GCM, ENCODING_SCHEME_UTF8, + INTEGRATION_BITBUCKET_API_URL, INTEGRATION_RAILWAY_API_URL, INTEGRATION_SET, INTEGRATION_VERCEL_API_URL, @@ -141,12 +142,14 @@ export const saveIntegrationAccessToken = async (req: Request, res: Response) => */ export const getIntegrationAuthApps = async (req: Request, res: Response) => { const teamId = req.query.teamId as string; + const workspaceSlug = req.query.workspaceSlug as string; const apps = await getApps({ integrationAuth: req.integrationAuth, accessToken: req.accessToken, accessId: req.accessId, - ...(teamId && { teamId }) + ...(teamId && { teamId }), + ...(workspaceSlug && { workspaceSlug }) }); return res.status(200).send({ @@ -382,6 +385,66 @@ export const getIntegrationAuthRailwayServices = async (req: Request, res: Respo }); }; +/** + * Return list of workspaces allowed for Bitbucket integration + * @param req + * @param res + * @returns + */ +export const getIntegrationAuthBitBucketWorkspaces = async (req: Request, res: Response) => { + + interface WorkspaceResponse { + size: number; + page: number; + pageLen: number; + next: string; + previous: string; + values: Array; + } + + interface Workspace { + type: string; + uuid: string; + name: string; + slug: string; + is_private: boolean; + created_on: string; + updated_on: string; + } + + const workspaces: Workspace[] = []; + let hasNextPage = true; + let workspaceUrl = `${INTEGRATION_BITBUCKET_API_URL}/2.0/workspaces` + + while (hasNextPage) { + const { data }: { data: WorkspaceResponse } = await standardRequest.get( + workspaceUrl, + { + headers: { + Authorization: `Bearer ${req.accessToken}`, + "Accept-Encoding": "application/json" + } + } + ); + + if (data?.values.length > 0) { + data.values.forEach((workspace) => { + workspaces.push(workspace) + }) + } + + if (data.next) { + workspaceUrl = data.next + } else { + hasNextPage = false + } + } + + return res.status(200).send({ + workspaces + }); +}; + /** * Delete integration authorization with id [integrationAuthId] * @param req diff --git a/backend/src/ee/routes/v1/sso.ts b/backend/src/ee/routes/v1/sso.ts index cf949f2f14..fb26e10649 100644 --- a/backend/src/ee/routes/v1/sso.ts +++ b/backend/src/ee/routes/v1/sso.ts @@ -11,8 +11,8 @@ import { ssoController } from "../../controllers/v1"; import { authLimiter } from "../../../helpers/rateLimiter"; import { ACCEPTED, - OWNER, - ADMIN + ADMIN, + OWNER } from "../../../variables"; router.get( diff --git a/backend/src/index.ts b/backend/src/index.ts index 1043b1616f..0235e601d0 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -35,6 +35,7 @@ import { membership as v1MembershipRouter, organization as v1OrganizationRouter, password as v1PasswordRouter, + secretImport as v1SecretImportRouter, secret as v1SecretRouter, secretScanning as v1SecretScanningRouter, secretsFolder as v1SecretsFolder, @@ -43,8 +44,7 @@ import { userAction as v1UserActionRouter, user as v1UserRouter, webhooks as v1WebhooksRouter, - workspace as v1WorkspaceRouter, - secretImport as v1SecretImportRouter + workspace as v1WorkspaceRouter } from "./routes/v1"; import { auth as v2AuthRouter, diff --git a/backend/src/integrations/apps.ts b/backend/src/integrations/apps.ts index 39dba64356..dea45ac992 100644 --- a/backend/src/integrations/apps.ts +++ b/backend/src/integrations/apps.ts @@ -5,12 +5,16 @@ import { INTEGRATION_AWS_PARAMETER_STORE, INTEGRATION_AWS_SECRET_MANAGER, INTEGRATION_AZURE_KEY_VAULT, + INTEGRATION_BITBUCKET, + INTEGRATION_BITBUCKET_API_URL, INTEGRATION_CHECKLY, INTEGRATION_CHECKLY_API_URL, INTEGRATION_CIRCLECI, INTEGRATION_CIRCLECI_API_URL, INTEGRATION_CLOUDFLARE_PAGES, INTEGRATION_CLOUDFLARE_PAGES_API_URL, + INTEGRATION_CODEFRESH, + INTEGRATION_CODEFRESH_API_URL, INTEGRATION_FLYIO, INTEGRATION_FLYIO_API_URL, INTEGRATION_GITHUB, @@ -31,10 +35,7 @@ import { INTEGRATION_TRAVISCI, INTEGRATION_TRAVISCI_API_URL, INTEGRATION_VERCEL, - INTEGRATION_VERCEL_API_URL, - INTEGRATION_CODEFRESH, - INTEGRATION_CODEFRESH_API_URL - + INTEGRATION_VERCEL_API_URL } from "../variables"; interface App { @@ -57,11 +58,13 @@ const getApps = async ({ accessToken, accessId, teamId, + workspaceSlug, }: { integrationAuth: IIntegrationAuth; accessToken: string; accessId?: string; teamId?: string; + workspaceSlug?: string; }) => { let apps: App[] = []; switch (integrationAuth.integration) { @@ -148,6 +151,12 @@ const getApps = async ({ accountId: accessId }) break; + case INTEGRATION_BITBUCKET: + apps = await getAppsBitBucket({ + accessToken, + workspaceSlug + }); + break; case INTEGRATION_CODEFRESH: apps = await getAppsCodefresh({ accessToken, @@ -729,6 +738,79 @@ const getAppsCloudflarePages = async ({ return apps; } +/** + * Return list of repositories for the BitBucket integration based on provided BitBucket workspace + * @param {Object} obj + * @param {String} obj.accessToken - access token for BitBucket API + * @param {String} obj.workspaceSlug - Workspace identifier for fetching BitBucket repositories + * @returns {Object[]} apps - BitBucket repositories + * @returns {String} apps.name - name of BitBucket repository + */ +const getAppsBitBucket = async ({ + accessToken, + workspaceSlug, +}: { + accessToken: string; + workspaceSlug?: string; +}) => { + interface RepositoriesResponse { + size: number; + page: number; + pageLen: number; + next: string; + previous: string; + values: Array; + } + + interface Repository { + type: string; + uuid: string; + name: string; + is_private: boolean; + created_on: string; + updated_on: string; + } + + if (!workspaceSlug) { + return [] + } + + const repositories: Repository[] = []; + let hasNextPage = true; + let repositoriesUrl = `${INTEGRATION_BITBUCKET_API_URL}/2.0/repositories/${workspaceSlug}` + + while (hasNextPage) { + const { data }: { data: RepositoriesResponse } = await standardRequest.get( + repositoriesUrl, + { + headers: { + Authorization: `Bearer ${accessToken}`, + "Accept": "application/json", + }, + } + ); + + if (data?.values.length > 0) { + data.values.forEach((repository) => { + repositories.push(repository) + }) + } + + if (data.next) { + repositoriesUrl = data.next + } else { + hasNextPage = false + } + } + + const apps = repositories.map((repository) => { + return { + name: repository.name, + appId: repository.uuid, + }; + }); + return apps; +} /** * Return list of projects for Supabase integration diff --git a/backend/src/integrations/exchange.ts b/backend/src/integrations/exchange.ts index 12948d4030..dddbb65c64 100644 --- a/backend/src/integrations/exchange.ts +++ b/backend/src/integrations/exchange.ts @@ -2,6 +2,8 @@ import { standardRequest } from "../config/request"; import { INTEGRATION_AZURE_KEY_VAULT, INTEGRATION_AZURE_TOKEN_URL, + INTEGRATION_BITBUCKET, + INTEGRATION_BITBUCKET_TOKEN_URL, INTEGRATION_GITHUB, INTEGRATION_GITHUB_TOKEN_URL, INTEGRATION_GITLAB, @@ -15,11 +17,13 @@ import { } from "../variables"; import { getClientIdAzure, + getClientIdBitBucket, getClientIdGitHub, getClientIdGitLab, getClientIdNetlify, getClientIdVercel, getClientSecretAzure, + getClientSecretBitBucket, getClientSecretGitHub, getClientSecretGitLab, getClientSecretHeroku, @@ -78,6 +82,15 @@ interface ExchangeCodeGitlabResponse { created_at: number; } +interface ExchangeCodeBitBucketResponse { + access_token: string; + token_type: string; + expires_in: number; + refresh_token: string; + scopes: string; + state: string; +} + /** * Return [accessToken], [accessExpiresAt], and [refreshToken] for OAuth2 * code-token exchange for integration named [integration] @@ -129,6 +142,12 @@ const exchangeCode = async ({ obj = await exchangeCodeGitlab({ code, }); + break; + case INTEGRATION_BITBUCKET: + obj = await exchangeCodeBitBucket({ + code, + }); + break; } return obj; @@ -347,4 +366,43 @@ const exchangeCodeGitlab = async ({ code }: { code: string }) => { }; }; +/** + * Return [accessToken], [accessExpiresAt], and [refreshToken] for BitBucket + * code-token exchange + * @param {Object} obj1 + * @param {Object} obj1.code - code for code-token exchange + * @returns {Object} obj2 + * @returns {String} obj2.accessToken - access token for BitBucket API + * @returns {String} obj2.refreshToken - refresh token for BitBucket API + * @returns {Date} obj2.accessExpiresAt - date of expiration for access token + */ +const exchangeCodeBitBucket = async ({ code }: { code: string }) => { + const accessExpiresAt = new Date(); + const res: ExchangeCodeBitBucketResponse = ( + await standardRequest.post( + INTEGRATION_BITBUCKET_TOKEN_URL, + new URLSearchParams({ + grant_type: "authorization_code", + code: code, + client_id: await getClientIdBitBucket(), + client_secret: await getClientSecretBitBucket(), + redirect_uri: `${await getSiteURL()}/integrations/bitbucket/oauth2/callback`, + } as any), + { + headers: { + "Accept-Encoding": "application/json", + }, + } + ) + ).data; + + accessExpiresAt.setSeconds(accessExpiresAt.getSeconds() + res.expires_in); + + return { + accessToken: res.access_token, + refreshToken: res.refresh_token, + accessExpiresAt, + }; +}; + export { exchangeCode }; diff --git a/backend/src/integrations/refresh.ts b/backend/src/integrations/refresh.ts index d530ab2f58..426d414f5a 100644 --- a/backend/src/integrations/refresh.ts +++ b/backend/src/integrations/refresh.ts @@ -2,6 +2,8 @@ import { standardRequest } from "../config/request"; import { IIntegrationAuth } from "../models"; import { INTEGRATION_AZURE_KEY_VAULT, + INTEGRATION_BITBUCKET, + INTEGRATION_BITBUCKET_TOKEN_URL, INTEGRATION_GITLAB, INTEGRATION_HEROKU, } from "../variables"; @@ -13,8 +15,10 @@ import { import { IntegrationService } from "../services"; import { getClientIdAzure, + getClientIdBitBucket, getClientIdGitLab, getClientSecretAzure, + getClientSecretBitBucket, getClientSecretGitLab, getClientSecretHeroku, getSiteURL, @@ -46,6 +50,15 @@ interface RefreshTokenGitLabResponse { created_at: number; } +interface RefreshTokenBitBucketResponse { + access_token: string; + token_type: string; + expires_in: number; + refresh_token: string; + scopes: string; + state: string; +} + /** * Return new access token by exchanging refresh token [refreshToken] for integration * named [integration] @@ -83,6 +96,11 @@ const exchangeRefresh = async ({ refreshToken, }); break; + case INTEGRATION_BITBUCKET: + tokenDetails = await exchangeRefreshBitBucket({ + refreshToken, + }); + break; default: throw new Error("Failed to exchange token for incompatible integration"); } @@ -218,4 +236,46 @@ const exchangeRefreshGitLab = async ({ }; }; +/** + * Return new access token by exchanging refresh token [refreshToken] for the + * BitBucket integration + * @param {Object} obj + * @param {String} obj.refreshToken - refresh token to use to get new access token for BitBucket + * @returns + */ +const exchangeRefreshBitBucket = async ({ + refreshToken, +}: { + refreshToken: string; +}) => { + const accessExpiresAt = new Date(); + const { + data, + }: { + data: RefreshTokenBitBucketResponse; + } = await standardRequest.post( + INTEGRATION_BITBUCKET_TOKEN_URL, + new URLSearchParams({ + grant_type: "refresh_token", + refresh_token: refreshToken, + client_id: await getClientIdBitBucket(), + client_secret: await getClientSecretBitBucket(), + redirect_uri: `${await getSiteURL()}/integrations/bitbucket/oauth2/callback`, + } as any), + { + headers: { + "Accept-Encoding": "application/json", + }, + } + ); + + accessExpiresAt.setSeconds(accessExpiresAt.getSeconds() + data.expires_in); + + return { + accessToken: data.access_token, + refreshToken: data.refresh_token, + accessExpiresAt, + }; +}; + export { exchangeRefresh }; diff --git a/backend/src/integrations/sync.ts b/backend/src/integrations/sync.ts index 129b3a63d2..2c2ed8d1ff 100644 --- a/backend/src/integrations/sync.ts +++ b/backend/src/integrations/sync.ts @@ -14,12 +14,16 @@ import { INTEGRATION_AWS_PARAMETER_STORE, INTEGRATION_AWS_SECRET_MANAGER, INTEGRATION_AZURE_KEY_VAULT, + INTEGRATION_BITBUCKET, + INTEGRATION_BITBUCKET_API_URL, INTEGRATION_CHECKLY, INTEGRATION_CHECKLY_API_URL, INTEGRATION_CIRCLECI, INTEGRATION_CIRCLECI_API_URL, INTEGRATION_CLOUDFLARE_PAGES, INTEGRATION_CLOUDFLARE_PAGES_API_URL, + INTEGRATION_CODEFRESH, + INTEGRATION_CODEFRESH_API_URL, INTEGRATION_FLYIO, INTEGRATION_FLYIO_API_URL, INTEGRATION_GITHUB, @@ -41,9 +45,7 @@ import { INTEGRATION_TRAVISCI, INTEGRATION_TRAVISCI_API_URL, INTEGRATION_VERCEL, - INTEGRATION_VERCEL_API_URL, - INTEGRATION_CODEFRESH, - INTEGRATION_CODEFRESH_API_URL + INTEGRATION_VERCEL_API_URL } from "../variables"; import { standardRequest } from "../config/request"; @@ -180,34 +182,6 @@ const syncSecrets = async ({ accessToken, }); break; - case INTEGRATION_FLYIO: - await syncSecretsFlyio({ - integration, - secrets, - accessToken, - }); - break; - case INTEGRATION_CIRCLECI: - await syncSecretsCircleCI({ - integration, - secrets, - accessToken, - }); - break; - case INTEGRATION_TRAVISCI: - await syncSecretsTravisCI({ - integration, - secrets, - accessToken, - }); - break; - case INTEGRATION_SUPABASE: - await syncSecretsSupabase({ - integration, - secrets, - accessToken, - }); - break; case INTEGRATION_CHECKLY: await syncSecretsCheckly({ integration, @@ -239,7 +213,14 @@ const syncSecrets = async ({ accessToken, }); break; - } + case INTEGRATION_BITBUCKET: + await syncSecretsBitBucket({ + integration, + secrets, + accessToken, + }); + break; + } }; /** @@ -706,8 +687,6 @@ const syncSecretsVercel = async ({ return true; }); - // return secret.target.includes(integration.targetEnvironment); - const res: { [key: string]: VercelSecret } = {}; for await (const vercelSecret of vercelSecrets) { @@ -1935,7 +1914,7 @@ const syncSecretsCloudflarePages = async ({ } ) ) - .data.result['deployment_configs'][integration.targetEnvironment]['env_vars']; + .data.result["deployment_configs"][integration.targetEnvironment]["env_vars"]; // copy the secrets object, so we can set deleted keys to null const secretsObj: any = { ...secrets }; @@ -1975,6 +1954,121 @@ const syncSecretsCloudflarePages = async ({ } /** + * Sync/push [secrets] to BitBucket repo with name [integration.app] + * @param {Object} obj + * @param {IIntegration} obj.integration - integration details + * @param {IIntegrationAuth} obj.integrationAuth - integration auth details + * @param {Object} obj.secrets - secrets to push to integration (object where keys are secret keys and values are secret values) + * @param {String} obj.accessToken - access token for BitBucket integration + */ +const syncSecretsBitBucket = async ({ + integration, + secrets, + accessToken, +}: { + integration: IIntegration; + secrets: any; + accessToken: string; +}) => { + interface VariablesResponse { + size: number; + page: number; + pageLen: number; + next: string; + previous: string; + values: Array; + } + + interface BitbucketVariable { + type: string; + uuid: string; + key: string; + value: string; + secured: boolean; + } + + const res: { [key: string]: BitbucketVariable } = {}; + + let hasNextPage = true; + let variablesUrl = `${INTEGRATION_BITBUCKET_API_URL}/2.0/repositories/${integration.targetEnvironmentId}/${integration.appId}/pipelines_config/variables` + + while (hasNextPage) { + const { data }: { data: VariablesResponse } = await standardRequest.get( + variablesUrl, + { + headers: { + Authorization: `Bearer ${accessToken}`, + "Accept": "application/json", + }, + } + ); + + if (data?.values.length > 0) { + data.values.forEach((variable) => { + res[variable.key] = variable; + }); + } + + if (data.next) { + variablesUrl = data.next + } else { + hasNextPage = false + } + } + + for await (const key of Object.keys(secrets)) { + if (key in res) { + // update existing secret + await standardRequest.put( + `${variablesUrl}/${res[key].uuid}`, + { + key, + value: secrets[key], + secured: true + }, + { + headers: { + Authorization: `Bearer ${accessToken}`, + "Accept": "application/json", + }, + } + ); + } else { + // create new secret + await standardRequest.post( + variablesUrl, + { + key, + value: secrets[key], + secured: true + }, + { + headers: { + Authorization: `Bearer ${accessToken}`, + "Accept": "application/json", + }, + } + ); + } + } + + for await (const key of Object.keys(res)) { + if (!(key in secrets)) { + // delete secret + await standardRequest.delete( + `${variablesUrl}/${res[key].uuid}`, + { + headers: { + Authorization: `Bearer ${accessToken}`, + "Accept": "application/json", + } + } + ); + } + } +} + +/* * Sync/push [secrets] to Codefresh with name [integration.app] * @param {Object} obj * @param {IIntegration} obj.integration - integration details diff --git a/backend/src/models/integration.ts b/backend/src/models/integration.ts index 75a5d41932..05e089f373 100644 --- a/backend/src/models/integration.ts +++ b/backend/src/models/integration.ts @@ -3,9 +3,11 @@ import { INTEGRATION_AWS_PARAMETER_STORE, INTEGRATION_AWS_SECRET_MANAGER, INTEGRATION_AZURE_KEY_VAULT, + INTEGRATION_BITBUCKET, INTEGRATION_CHECKLY, INTEGRATION_CIRCLECI, INTEGRATION_CLOUDFLARE_PAGES, + INTEGRATION_CODEFRESH, INTEGRATION_FLYIO, INTEGRATION_GITHUB, INTEGRATION_GITLAB, @@ -17,8 +19,7 @@ import { INTEGRATION_RENDER, INTEGRATION_SUPABASE, INTEGRATION_TRAVISCI, - INTEGRATION_VERCEL, - INTEGRATION_CODEFRESH + INTEGRATION_VERCEL } from "../variables"; export interface IIntegration { @@ -56,7 +57,8 @@ export interface IIntegration { | "checkly" | "hashicorp-vault" | "cloudflare-pages" - | "codefresh"; + | "bitbucket" + | "codefresh" integrationAuth: Types.ObjectId; } @@ -146,6 +148,7 @@ const integrationSchema = new Schema( INTEGRATION_CHECKLY, INTEGRATION_HASHICORP_VAULT, INTEGRATION_CLOUDFLARE_PAGES, + INTEGRATION_BITBUCKET, INTEGRATION_CODEFRESH ], required: true, diff --git a/backend/src/models/integrationAuth.ts b/backend/src/models/integrationAuth.ts index 2330c79383..409249bf36 100644 --- a/backend/src/models/integrationAuth.ts +++ b/backend/src/models/integrationAuth.ts @@ -6,8 +6,10 @@ import { INTEGRATION_AWS_PARAMETER_STORE, INTEGRATION_AWS_SECRET_MANAGER, INTEGRATION_AZURE_KEY_VAULT, + INTEGRATION_BITBUCKET, INTEGRATION_CIRCLECI, INTEGRATION_CLOUDFLARE_PAGES, + INTEGRATION_CODEFRESH, INTEGRATION_FLYIO, INTEGRATION_GITHUB, INTEGRATION_GITLAB, @@ -19,14 +21,32 @@ import { INTEGRATION_RENDER, INTEGRATION_SUPABASE, INTEGRATION_TRAVISCI, - INTEGRATION_VERCEL, - INTEGRATION_CODEFRESH + INTEGRATION_VERCEL } from "../variables"; export interface IIntegrationAuth extends Document { _id: Types.ObjectId; workspace: Types.ObjectId; - integration: 'heroku' | 'vercel' | 'netlify' | 'github' | 'gitlab' | 'render' | 'railway' | 'flyio' | 'azure-key-vault' | 'laravel-forge' | 'circleci' | 'travisci' | 'supabase' | 'aws-parameter-store' | 'aws-secret-manager' | 'checkly' | 'cloudflare-pages' | 'codefresh'; + integration: + | "heroku" + | "vercel" + | "netlify" + | "github" + | "gitlab" + | "render" + | "railway" + | "flyio" + | "azure-key-vault" + | "laravel-forge" + | "circleci" + | "travisci" + | "supabase" + | "aws-parameter-store" + | "aws-secret-manager" + | "checkly" + | "cloudflare-pages" + | "codefresh" + | "bitbucket"; teamId: string; accountId: string; url: string; @@ -72,6 +92,7 @@ const integrationAuthSchema = new Schema( INTEGRATION_SUPABASE, INTEGRATION_HASHICORP_VAULT, INTEGRATION_CLOUDFLARE_PAGES, + INTEGRATION_BITBUCKET, INTEGRATION_CODEFRESH ], required: true, diff --git a/backend/src/routes/v1/integrationAuth.ts b/backend/src/routes/v1/integrationAuth.ts index 28fa65af80..673d269d28 100644 --- a/backend/src/routes/v1/integrationAuth.ts +++ b/backend/src/routes/v1/integrationAuth.ts @@ -81,6 +81,7 @@ router.get( }), param("integrationAuthId"), query("teamId"), + query("workspaceSlug"), validateRequest, integrationAuthController.getIntegrationAuthApps ); @@ -141,6 +142,19 @@ router.get( integrationAuthController.getIntegrationAuthRailwayServices ); +router.get( + "/:integrationAuthId/bitbucket/workspaces", + requireAuth({ + acceptedAuthModes: [AUTH_MODE_JWT], + }), + requireIntegrationAuthorizationAuth({ + acceptedRoles: [ADMIN, MEMBER], + }), + param("integrationAuthId").exists().isString(), + validateRequest, + integrationAuthController.getIntegrationAuthBitBucketWorkspaces +); + router.delete( "/:integrationAuthId", requireAuth({ diff --git a/backend/src/utils/aes-gcm.ts b/backend/src/utils/aes-gcm.ts index 4457616a6f..21734611b9 100644 --- a/backend/src/utils/aes-gcm.ts +++ b/backend/src/utils/aes-gcm.ts @@ -4,8 +4,6 @@ const ALGORITHM = "aes-256-gcm"; const BLOCK_SIZE_BYTES = 16; export default class AesGCM { - constructor() {} - static encrypt( text: string, secret: string diff --git a/backend/src/utils/auth.ts b/backend/src/utils/auth.ts index aebafa5a30..dccd5b5e4b 100644 --- a/backend/src/utils/auth.ts +++ b/backend/src/utils/auth.ts @@ -4,11 +4,11 @@ import { Types } from "mongoose"; import { AuthData } from "../interfaces/middleware"; import { AuthProvider, + MembershipOrg, + Organization, ServiceAccount, ServiceTokenData, - User, - Organization, - MembershipOrg + User } from "../models"; import { createToken } from "../helpers/auth"; import { @@ -18,8 +18,8 @@ import { getJwtProviderAuthSecret, } from "../config"; import { getSSOConfigHelper } from "../ee/helpers/organizations"; -import { OrganizationNotFoundError, InternalServerError } from "./errors"; -import { MEMBER, INVITED } from "../variables"; +import { InternalServerError, OrganizationNotFoundError } from "./errors"; +import { INVITED, MEMBER } from "../variables"; import { getSiteURL } from "../config"; // eslint-disable-next-line @typescript-eslint/no-var-requires diff --git a/backend/src/variables/integration.ts b/backend/src/variables/integration.ts index ba3f0e9058..e99b5a3bf8 100644 --- a/backend/src/variables/integration.ts +++ b/backend/src/variables/integration.ts @@ -1,5 +1,6 @@ import { getClientIdAzure, + getClientIdBitBucket, getClientIdGitHub, getClientIdGitLab, getClientIdHeroku, @@ -26,6 +27,7 @@ export const INTEGRATION_SUPABASE = "supabase"; export const INTEGRATION_CHECKLY = "checkly"; export const INTEGRATION_HASHICORP_VAULT = "hashicorp-vault"; export const INTEGRATION_CLOUDFLARE_PAGES = "cloudflare-pages"; +export const INTEGRATION_BITBUCKET = "bitbucket"; export const INTEGRATION_CODEFRESH = "codefresh"; export const INTEGRATION_SET = new Set([ INTEGRATION_AZURE_KEY_VAULT, @@ -43,6 +45,7 @@ export const INTEGRATION_SET = new Set([ INTEGRATION_CHECKLY, INTEGRATION_HASHICORP_VAULT, INTEGRATION_CLOUDFLARE_PAGES, + INTEGRATION_BITBUCKET, INTEGRATION_CODEFRESH ]); @@ -58,6 +61,7 @@ export const INTEGRATION_NETLIFY_TOKEN_URL = "https://api.netlify.com/oauth/toke export const INTEGRATION_GITHUB_TOKEN_URL = "https://github.com/login/oauth/access_token"; export const INTEGRATION_GITLAB_TOKEN_URL = "https://gitlab.com/oauth/token"; +export const INTEGRATION_BITBUCKET_TOKEN_URL = "https://bitbucket.org/site/oauth2/access_token" // integration apps endpoints export const INTEGRATION_HEROKU_API_URL = "https://api.heroku.com"; @@ -73,6 +77,7 @@ export const INTEGRATION_SUPABASE_API_URL = "https://api.supabase.com"; export const INTEGRATION_LARAVELFORGE_API_URL = "https://forge.laravel.com"; export const INTEGRATION_CHECKLY_API_URL = "https://api.checklyhq.com"; export const INTEGRATION_CLOUDFLARE_PAGES_API_URL = "https://api.cloudflare.com"; +export const INTEGRATION_BITBUCKET_API_URL = "https://api.bitbucket.org"; export const INTEGRATION_CODEFRESH_API_URL = "https://g.codefresh.io/api"; export const getIntegrationOptions = async () => { @@ -249,6 +254,15 @@ export const getIntegrationOptions = async () => { clientId: "", docsLink: "" }, + { + name: "BitBucket", + slug: "bitbucket", + image: "BitBucket.png", + isAvailable: true, + type: "oauth", + clientId: await getClientIdBitBucket(), + docsLink: "" + }, { name: "Codefresh", slug: "codefresh", @@ -257,7 +271,7 @@ export const getIntegrationOptions = async () => { type: "pat", clientId: "", docsLink: "", - }, + } ] return INTEGRATION_OPTIONS; diff --git a/docs/images/integrations-bitbucket-auth.png b/docs/images/integrations-bitbucket-auth.png new file mode 100644 index 0000000000..edacf10850 Binary files /dev/null and b/docs/images/integrations-bitbucket-auth.png differ diff --git a/docs/images/integrations-bitbucket.png b/docs/images/integrations-bitbucket.png new file mode 100644 index 0000000000..fb1f47e7b0 Binary files /dev/null and b/docs/images/integrations-bitbucket.png differ diff --git a/docs/integrations/cicd/bitbucket.mdx b/docs/integrations/cicd/bitbucket.mdx new file mode 100644 index 0000000000..183ec075b2 --- /dev/null +++ b/docs/integrations/cicd/bitbucket.mdx @@ -0,0 +1,29 @@ +--- +title: "BitBucket" +description: "How to sync secrets from Infisical to BitBucket" +--- + +Prerequisites: + +- Set up and add envars to [Infisical Cloud](https://app.infisical.com) + +## Navigate to your project's integrations tab + +![integrations](../../images/integrations.png) + +## Authorize Infisical for BitBucket + +Press on the BitBucket tile and grant Infisical access to your BitBucket account. + +![integrations bitbucket authorization](../../images/integrations-bitbucket-auth.png) + + + If this is your project's first cloud integration, then you'll have to grant Infisical access to your project's environment variables. + Although this step breaks E2EE, it's necessary for Infisical to sync the environment variables to the cloud platform. + + +## Start integration + +Select which Infisical environment secrets you want to sync to which BitBucket repo and press start integration to start syncing secrets to the repo. + +![integrations bitbucket](../../images/integrations-bitbucket.png) diff --git a/docs/integrations/overview.mdx b/docs/integrations/overview.mdx index b054a52cd1..decc261848 100644 --- a/docs/integrations/overview.mdx +++ b/docs/integrations/overview.mdx @@ -28,6 +28,7 @@ Missing an integration? [Throw in a request](https://github.com/Infisical/infisi | [AWS Parameter Store](/integrations/cloud/aws-parameter-store) | Cloud | Available | | [AWS Secret Manager](/integrations/cloud/aws-secret-manager) | Cloud | Available | | [Azure Key Vault](/integrations/cloud/azure-key-vault) | Cloud | Available | +| [BitBucket](/integrations/cicd/bitbucket) | CI/CD | Available | | [GitHub Actions](/integrations/cicd/githubactions) | CI/CD | Available | | [GitLab](/integrations/cicd/gitlab) | CI/CD | Available | | [CircleCI](/integrations/cicd/circleci) | CI/CD | Available | diff --git a/docs/mint.json b/docs/mint.json index 38f904ef40..aa47d9d8a3 100644 --- a/docs/mint.json +++ b/docs/mint.json @@ -222,7 +222,8 @@ "integrations/cicd/githubactions", "integrations/cicd/gitlab", "integrations/cicd/circleci", - "integrations/cicd/travisci" + "integrations/cicd/travisci", + "integrations/cicd/bitbucket" ] }, { diff --git a/docs/self-hosting/configuration/envars.mdx b/docs/self-hosting/configuration/envars.mdx index fddf6f82ed..1db8822602 100644 --- a/docs/self-hosting/configuration/envars.mdx +++ b/docs/self-hosting/configuration/envars.mdx @@ -107,6 +107,14 @@ Other environment variables are listed below to increase the functionality of yo OAuth2 slug for Vercel integration + + + OAuth2 client ID for BitBucket integration + + + + OAuth2 client secret for BitBucket integration + To integrate with external auth providers, provide value for the related keys diff --git a/frontend/public/data/frequentConstants.ts b/frontend/public/data/frequentConstants.ts index 17c30d38c9..d4c0aa7290 100644 --- a/frontend/public/data/frequentConstants.ts +++ b/frontend/public/data/frequentConstants.ts @@ -3,26 +3,27 @@ interface Mapping { } const integrationSlugNameMapping: Mapping = { - 'azure-key-vault': 'Azure Key Vault', - 'aws-parameter-store': 'AWS Parameter Store', - 'aws-secret-manager': 'AWS Secret Manager', - 'heroku': 'Heroku', - 'vercel': 'Vercel', - 'netlify': 'Netlify', - 'github': 'GitHub', - 'gitlab': 'GitLab', - 'render': 'Render', - 'laravel-forge': "Laravel Forge", - 'railway': 'Railway', - 'flyio': 'Fly.io', - 'circleci': 'CircleCI', - 'travisci': 'TravisCI', - 'supabase': 'Supabase', - 'checkly': 'Checkly', - 'hashicorp-vault': 'Vault', - 'cloudflare-pages': 'Cloudflare Pages', - 'codefresh': 'Codefresh' -} + "azure-key-vault": "Azure Key Vault", + "aws-parameter-store": "AWS Parameter Store", + "aws-secret-manager": "AWS Secret Manager", + heroku: "Heroku", + vercel: "Vercel", + netlify: "Netlify", + github: "GitHub", + gitlab: "GitLab", + render: "Render", + "laravel-forge": "Laravel Forge", + railway: "Railway", + flyio: "Fly.io", + circleci: "CircleCI", + travisci: "TravisCI", + supabase: "Supabase", + checkly: "Checkly", + "hashicorp-vault": "Vault", + "cloudflare-pages": "Cloudflare Pages", + "codefresh": "Codefresh", + bitbucket: "BitBucket" +}; const envMapping: Mapping = { Development: "dev", diff --git a/frontend/public/images/integrations/BitBucket.png b/frontend/public/images/integrations/BitBucket.png new file mode 100644 index 0000000000..7fe9b525ce Binary files /dev/null and b/frontend/public/images/integrations/BitBucket.png differ diff --git a/frontend/src/hooks/api/integrationAuth/index.tsx b/frontend/src/hooks/api/integrationAuth/index.tsx index c9fda017f8..a227cdae54 100644 --- a/frontend/src/hooks/api/integrationAuth/index.tsx +++ b/frontend/src/hooks/api/integrationAuth/index.tsx @@ -1,6 +1,7 @@ export { useDeleteIntegrationAuth, useGetIntegrationAuthApps, + useGetIntegrationAuthBitBucketWorkspaces, useGetIntegrationAuthById, useGetIntegrationAuthRailwayEnvironments, useGetIntegrationAuthRailwayServices, diff --git a/frontend/src/hooks/api/integrationAuth/queries.tsx b/frontend/src/hooks/api/integrationAuth/queries.tsx index f901260093..a5aadd7341 100644 --- a/frontend/src/hooks/api/integrationAuth/queries.tsx +++ b/frontend/src/hooks/api/integrationAuth/queries.tsx @@ -3,13 +3,13 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { apiRequest } from "@app/config/request"; import { workspaceKeys } from "../workspace/queries"; -import { App, Environment, IntegrationAuth, Service, Team } from "./types"; +import { App, BitBucketWorkspace, Environment, IntegrationAuth, Service, Team } from "./types"; const integrationAuthKeys = { getIntegrationAuthById: (integrationAuthId: string) => [{ integrationAuthId }, "integrationAuth"] as const, - getIntegrationAuthApps: (integrationAuthId: string, teamId?: string) => - [{ integrationAuthId, teamId }, "integrationAuthApps"] as const, + getIntegrationAuthApps: (integrationAuthId: string, teamId?: string, workspaceSlug?: string) => + [{ integrationAuthId, teamId, workspaceSlug }, "integrationAuthApps"] as const, getIntegrationAuthTeams: (integrationAuthId: string) => [{ integrationAuthId }, "integrationAuthTeams"] as const, getIntegrationAuthVercelBranches: ({ @@ -33,7 +33,9 @@ const integrationAuthKeys = { }: { integrationAuthId: string; appId: string; - }) => [{ integrationAuthId, appId }, "integrationAuthRailwayServices"] as const + }) => [{ integrationAuthId, appId }, "integrationAuthRailwayServices"] as const, + getIntegrationAuthBitBucketWorkspaces: (integrationAuthId: string) => + [{ integrationAuthId }, "integrationAuthBitbucketWorkspaces"] as const, }; const fetchIntegrationAuthById = async (integrationAuthId: string) => { @@ -45,12 +47,22 @@ const fetchIntegrationAuthById = async (integrationAuthId: string) => { const fetchIntegrationAuthApps = async ({ integrationAuthId, - teamId + teamId, + workspaceSlug }: { integrationAuthId: string; teamId?: string; + workspaceSlug?: string; }) => { - const searchParams = new URLSearchParams(teamId ? { teamId } : undefined); + const params: Record = {} + if (teamId) { + params.teamId = teamId + } + if (workspaceSlug) { + params.workspaceSlug = workspaceSlug + } + + const searchParams = new URLSearchParams(params); const { data } = await apiRequest.get<{ apps: App[] }>( `/api/v1/integration-auth/${integrationAuthId}/apps`, { params: searchParams } @@ -129,6 +141,13 @@ const fetchIntegrationAuthRailwayServices = async ({ return services; }; +const fetchIntegrationAuthBitBucketWorkspaces = async (integrationAuthId: string) => { + const { data: { workspaces } } = await apiRequest.get<{ workspaces: BitBucketWorkspace[] }>( + `/api/v1/integration-auth/${integrationAuthId}/bitbucket/workspaces` + ); + return workspaces; +}; + export const useGetIntegrationAuthById = (integrationAuthId: string) => { return useQuery({ queryKey: integrationAuthKeys.getIntegrationAuthById(integrationAuthId), @@ -139,17 +158,20 @@ export const useGetIntegrationAuthById = (integrationAuthId: string) => { export const useGetIntegrationAuthApps = ({ integrationAuthId, - teamId + teamId, + workspaceSlug, }: { integrationAuthId: string; teamId?: string; + workspaceSlug?: string; }) => { return useQuery({ - queryKey: integrationAuthKeys.getIntegrationAuthApps(integrationAuthId, teamId), + queryKey: integrationAuthKeys.getIntegrationAuthApps(integrationAuthId, teamId, workspaceSlug), queryFn: () => fetchIntegrationAuthApps({ integrationAuthId, - teamId + teamId, + workspaceSlug }), enabled: true }); @@ -226,6 +248,13 @@ export const useGetIntegrationAuthRailwayServices = ({ }); }; +export const useGetIntegrationAuthBitBucketWorkspaces = (integrationAuthId: string) => { + return useQuery({ + queryKey: integrationAuthKeys.getIntegrationAuthBitBucketWorkspaces(integrationAuthId), + queryFn: () => fetchIntegrationAuthBitBucketWorkspaces(integrationAuthId), + enabled: true + }); +}; export const useDeleteIntegrationAuth = () => { const queryClient = useQueryClient(); diff --git a/frontend/src/hooks/api/integrationAuth/types.ts b/frontend/src/hooks/api/integrationAuth/types.ts index 73440cc8d3..883f410407 100644 --- a/frontend/src/hooks/api/integrationAuth/types.ts +++ b/frontend/src/hooks/api/integrationAuth/types.ts @@ -29,3 +29,9 @@ export type Service = { name: string; serviceId: string; }; + +export type BitBucketWorkspace = { + uuid: string; + name: string; + slug: string; +} \ No newline at end of file diff --git a/frontend/src/pages/integrations/bitbucket/create.tsx b/frontend/src/pages/integrations/bitbucket/create.tsx new file mode 100644 index 0000000000..6bdc87f0dc --- /dev/null +++ b/frontend/src/pages/integrations/bitbucket/create.tsx @@ -0,0 +1,198 @@ +import { useEffect, useState } from "react"; +import { useRouter } from "next/router"; +import queryString from "query-string"; + +import { + Button, + Card, + CardTitle, + FormControl, + Input, + Select, + SelectItem +} from "../../../components/v2"; +import { + useGetIntegrationAuthApps, + useGetIntegrationAuthBitBucketWorkspaces, + useGetIntegrationAuthById, +} from "../../../hooks/api/integrationAuth"; +import { useGetWorkspaceById } from "../../../hooks/api/workspace"; +import createIntegration from "../../api/integrations/createIntegration"; + +export default function BitBucketCreateIntegrationPage() { + const router = useRouter(); + + const [targetAppId, setTargetAppId] = useState(""); + const [targetEnvironmentId, setTargetEnvironmentId] = useState(""); + + const [selectedSourceEnvironment, setSelectedSourceEnvironment] = useState(""); + const [secretPath, setSecretPath] = useState("/"); + const [isLoading, setIsLoading] = useState(false); + + const { integrationAuthId } = queryString.parse(router.asPath.split("?")[1]); + const { data: integrationAuth } = useGetIntegrationAuthById((integrationAuthId as string) ?? ""); + const { data: workspace } = useGetWorkspaceById(localStorage.getItem("projectData.id") ?? ""); + const { data: targetEnvironments } = useGetIntegrationAuthBitBucketWorkspaces((integrationAuthId as string) ?? ""); + const { data: integrationAuthApps } = useGetIntegrationAuthApps({ + integrationAuthId: (integrationAuthId as string) ?? "", + workspaceSlug: targetEnvironmentId + }); + + useEffect(() => { + if (workspace) { + setSelectedSourceEnvironment(workspace.environments[0].slug); + } + }, [workspace]); + + useEffect(() => { + if (integrationAuthApps) { + if (integrationAuthApps.length > 0) { + setTargetAppId(integrationAuthApps[0].appId as string); + } else { + setTargetAppId("none"); + } + } + }, [integrationAuthApps]); + + useEffect(() => { + if (targetEnvironments) { + if (targetEnvironments.length > 0) { + setTargetEnvironmentId(targetEnvironments[0].slug); + } else { + setTargetEnvironmentId("none"); + } + } + }, [targetEnvironments]); + + const handleButtonClick = async () => { + try { + setIsLoading(true); + + if (!integrationAuth?._id) return; + + const targetApp = integrationAuthApps?.find( + (integrationAuthApp) => integrationAuthApp.appId === targetAppId + ); + const targetEnvironment = targetEnvironments?.find( + (environment) => environment.slug === targetEnvironmentId + ); + + if (!targetApp || !targetApp.appId || !targetEnvironment) return; + + await createIntegration({ + integrationAuthId: integrationAuth?._id, + isActive: true, + app: targetApp.name, + appId: targetApp.appId, + sourceEnvironment: selectedSourceEnvironment, + targetEnvironment: targetEnvironment.name, + targetEnvironmentId: targetEnvironment.slug, + targetService: null, + targetServiceId: null, + owner: null, + path: null, + region: null, + secretPath + }); + + setIsLoading(false); + + router.push(`/integrations/${localStorage.getItem("projectData.id")}`); + } catch (err) { + console.error(err); + } + }; + + return integrationAuth && + workspace && + selectedSourceEnvironment && + integrationAuthApps && + targetEnvironments ? ( +
+ + BitBucket Integration + + + + + setSecretPath(evt.target.value)} + placeholder="Provide a path, default is /" + /> + + + + + + + + + +
+ ) : ( +
+ ); +} + +BitBucketCreateIntegrationPage.requireAuth = true; diff --git a/frontend/src/pages/integrations/bitbucket/oauth2/callback.tsx b/frontend/src/pages/integrations/bitbucket/oauth2/callback.tsx new file mode 100644 index 0000000000..43a58bc6f1 --- /dev/null +++ b/frontend/src/pages/integrations/bitbucket/oauth2/callback.tsx @@ -0,0 +1,34 @@ +import { useEffect } from "react"; +import { useRouter } from "next/router"; +import queryString from "query-string"; + +import AuthorizeIntegration from "../../../api/integrations/authorizeIntegration"; + +export default function BitBucketOAuth2CallbackPage() { + const router = useRouter(); + const { code, state } = queryString.parse(router.asPath.split("?")[1]); + + useEffect(() => { + (async () => { + try { + // validate state + if (state !== localStorage.getItem("latestCSRFToken")) return; + localStorage.removeItem("latestCSRFToken"); + + const integrationAuth = await AuthorizeIntegration({ + workspaceId: localStorage.getItem("projectData.id") as string, + code: code as string, + integration: "bitbucket" + }); + + router.push(`/integrations/bitbucket/create?integrationAuthId=${integrationAuth._id}`); + } catch (err) { + console.error(err); + } + })(); + }, []); + + return
; +} + +BitBucketOAuth2CallbackPage.requireAuth = true; diff --git a/frontend/src/views/IntegrationsPage/IntegrationPage.utils.tsx b/frontend/src/views/IntegrationsPage/IntegrationPage.utils.tsx index e2cfbc5ed0..a5be325021 100644 --- a/frontend/src/views/IntegrationsPage/IntegrationPage.utils.tsx +++ b/frontend/src/views/IntegrationsPage/IntegrationPage.utils.tsx @@ -92,10 +92,12 @@ export const redirectForProviderAuth = (integrationOption: TCloudIntegration) => case "cloudflare-pages": link = `${window.location.origin}/integrations/cloudflare-pages/authorize`; break; - case "codefresh": + case "bitbucket": + link = `https://bitbucket.org/site/oauth2/authorize?client_id=${integrationOption.clientId}&response_type=code&redirect_uri=${window.location.origin}/integrations/bitbucket/oauth2/callback&state=${state}`; + break; + case "codefresh": link = `${window.location.origin}/integrations/codefresh/authorize`; break; - default: break; } diff --git a/frontend/src/views/IntegrationsPage/components/IntegrationsSection/IntegrationsSection.tsx b/frontend/src/views/IntegrationsPage/components/IntegrationsSection/IntegrationsSection.tsx index fd8f72887f..a058edf9aa 100644 --- a/frontend/src/views/IntegrationsPage/components/IntegrationsSection/IntegrationsSection.tsx +++ b/frontend/src/views/IntegrationsPage/components/IntegrationsSection/IntegrationsSection.tsx @@ -105,7 +105,8 @@ export const IntegrationsSection = ({ {(integration.integration === "vercel" || integration.integration === "netlify" || integration.integration === "railway" || - integration.integration === "gitlab") && ( + integration.integration === "gitlab" || + integration.integration === "bitbucket") && (