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 e023a12c88..0259c829ed 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..9072f459b9 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 integration with integration authorization id [integrationAuthId] + * @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/integrations/apps.ts b/backend/src/integrations/apps.ts index b2fec9292e..e68f29f56e 100644 --- a/backend/src/integrations/apps.ts +++ b/backend/src/integrations/apps.ts @@ -5,6 +5,8 @@ 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, @@ -54,11 +56,13 @@ const getApps = async ({ accessToken, accessId, teamId, + workspaceSlug, }: { integrationAuth: IIntegrationAuth; accessToken: string; accessId?: string; teamId?: string; + workspaceSlug?: string; }) => { let apps: App[] = []; switch (integrationAuth.integration) { @@ -145,6 +149,11 @@ const getApps = async ({ accountId: accessId }) break; + case INTEGRATION_BITBUCKET: + apps = await getAppsBitBucket({ + accessToken, + workspaceSlug + }) } return apps; @@ -721,4 +730,78 @@ 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; +} + export { getApps }; diff --git a/backend/src/integrations/exchange.ts b/backend/src/integrations/exchange.ts index 12948d4030..da42a00fc7 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,11 @@ const exchangeCode = async ({ obj = await exchangeCodeGitlab({ code, }); + break; + case INTEGRATION_BITBUCKET: + obj = await exchangeCodeBitBucket({ + code, + }) } return obj; @@ -347,4 +365,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 5d8efa1019..7b7456b86f 100644 --- a/backend/src/integrations/sync.ts +++ b/backend/src/integrations/sync.ts @@ -14,6 +14,8 @@ 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, @@ -202,7 +204,14 @@ const syncSecrets = async ({ accessToken }); break; - } + case INTEGRATION_BITBUCKET: + await syncSecretsBitBucket({ + integration, + secrets, + accessToken, + }); + break; + } }; /** @@ -1937,4 +1946,122 @@ 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 Variable { + type: string; + uuid: string; + key: string; + value: string; + secured: boolean; + } + + const existingSecrets: Variable[] = []; + const workspaceSlug = integration.targetEnvironmentId + const repoSlug = integration.appId + let hasNextPage = true; + let variablesUrl = `${INTEGRATION_BITBUCKET_API_URL}/2.0/repositories/${workspaceSlug}/${repoSlug}/pipelines_config/variables` + + // Fetch all repository 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) => { + existingSecrets.push(variable) + }) + } + + if (data.next) { + variablesUrl = data.next + } else { + hasNextPage = false + } + } + + Object.keys(secrets).forEach(async (key) => { + const existingSecret = existingSecrets.find((secret) => secret.key.toUpperCase() === key.toUpperCase()); + if (existingSecret) { + // Update existing secrets + await standardRequest.put( + `${variablesUrl}/${existingSecret.uuid}`, + { + key, + value: secrets[key], + secured: true + }, + { + headers: { + Authorization: `Bearer ${accessToken}`, + "Accept": "application/json", + }, + } + ); + } else { + // Create new secrets + await standardRequest.post( + variablesUrl, + { + key, + value: secrets[key], + secured: true + }, + { + headers: { + Authorization: `Bearer ${accessToken}`, + "Accept": "application/json", + }, + } + ); + } + }) + + // Delete secrets + existingSecrets.forEach(async (existingSecret) => { + if (!(existingSecret.key in secrets) && existingSecret.secured) { + await standardRequest.delete( + `${variablesUrl}/${existingSecret.uuid}`, + { + headers: { + Authorization: `Bearer ${accessToken}`, + "Accept": "application/json", + }, + } + ); + } + }) +} + export { syncSecrets }; diff --git a/backend/src/models/integration.ts b/backend/src/models/integration.ts index c4021977db..2599e3cbcc 100644 --- a/backend/src/models/integration.ts +++ b/backend/src/models/integration.ts @@ -3,6 +3,7 @@ import { INTEGRATION_AWS_PARAMETER_STORE, INTEGRATION_AWS_SECRET_MANAGER, INTEGRATION_AZURE_KEY_VAULT, + INTEGRATION_BITBUCKET, INTEGRATION_CHECKLY, INTEGRATION_CIRCLECI, INTEGRATION_CLOUDFLARE_PAGES, @@ -54,7 +55,8 @@ export interface IIntegration { | "supabase" | "checkly" | "hashicorp-vault" - | "cloudflare-pages"; + | "cloudflare-pages" + | "bitbucket"; integrationAuth: Types.ObjectId; } @@ -144,6 +146,7 @@ const integrationSchema = new Schema( INTEGRATION_CHECKLY, INTEGRATION_HASHICORP_VAULT, INTEGRATION_CLOUDFLARE_PAGES, + INTEGRATION_BITBUCKET, ], required: true, }, diff --git a/backend/src/models/integrationAuth.ts b/backend/src/models/integrationAuth.ts index ed3dabadfb..26073c3b03 100644 --- a/backend/src/models/integrationAuth.ts +++ b/backend/src/models/integrationAuth.ts @@ -6,6 +6,7 @@ import { INTEGRATION_AWS_PARAMETER_STORE, INTEGRATION_AWS_SECRET_MANAGER, INTEGRATION_AZURE_KEY_VAULT, + INTEGRATION_BITBUCKET, INTEGRATION_CIRCLECI, INTEGRATION_CLOUDFLARE_PAGES, INTEGRATION_FLYIO, @@ -25,7 +26,25 @@ import { 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"; + 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" + | "bitbucket"; teamId: string; accountId: string; url: string; @@ -71,6 +90,7 @@ const integrationAuthSchema = new Schema( INTEGRATION_SUPABASE, INTEGRATION_HASHICORP_VAULT, INTEGRATION_CLOUDFLARE_PAGES, + INTEGRATION_BITBUCKET ], required: true, }, diff --git a/backend/src/routes/v1/integrationAuth.ts b/backend/src/routes/v1/integrationAuth.ts index 28fa65af80..5e5f1e6d17 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"), + validateRequest, + integrationAuthController.getIntegrationAuthBitBucketWorkspaces +); + router.delete( "/:integrationAuthId", requireAuth({ diff --git a/backend/src/variables/integration.ts b/backend/src/variables/integration.ts index 18eaeb0494..fc4397871b 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_SET = new Set([ INTEGRATION_AZURE_KEY_VAULT, INTEGRATION_HEROKU, @@ -41,7 +43,8 @@ export const INTEGRATION_SET = new Set([ INTEGRATION_SUPABASE, INTEGRATION_CHECKLY, INTEGRATION_HASHICORP_VAULT, - INTEGRATION_CLOUDFLARE_PAGES + INTEGRATION_CLOUDFLARE_PAGES, + INTEGRATION_BITBUCKET ]); // integration types @@ -56,6 +59,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"; @@ -71,6 +75,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 getIntegrationOptions = async () => { const INTEGRATION_OPTIONS = [ @@ -245,6 +250,15 @@ export const getIntegrationOptions = async () => { type: "pat", clientId: "", docsLink: "" + }, + { + name: "BitBucket", + slug: "bitbucket", + image: "BitBucket.png", + isAvailable: true, + type: "oauth", + clientId: await getClientIdBitBucket(), + docsLink: "" } ] 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 a749524ef8..e441f0ada9 100644 --- a/docs/mint.json +++ b/docs/mint.json @@ -220,7 +220,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 c3c74091d2..64f824de2a 100644 --- a/frontend/public/data/frequentConstants.ts +++ b/frontend/public/data/frequentConstants.ts @@ -3,25 +3,26 @@ 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' -} + "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", + 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 3b18be83f0..a227cdae54 100644 --- a/frontend/src/hooks/api/integrationAuth/index.tsx +++ b/frontend/src/hooks/api/integrationAuth/index.tsx @@ -1,9 +1,10 @@ export { useDeleteIntegrationAuth, useGetIntegrationAuthApps, + useGetIntegrationAuthBitBucketWorkspaces, useGetIntegrationAuthById, useGetIntegrationAuthRailwayEnvironments, useGetIntegrationAuthRailwayServices, useGetIntegrationAuthTeams, - useGetIntegrationAuthVercelBranches + useGetIntegrationAuthVercelBranches, } from "./queries"; diff --git a/frontend/src/hooks/api/integrationAuth/queries.tsx b/frontend/src/hooks/api/integrationAuth/queries.tsx index aed885ff31..841c1a52b0 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: ({ @@ -32,7 +32,9 @@ const integrationAuthKeys = { }: { integrationAuthId: string; appId: string; - }) => [{ integrationAuthId, appId }, "integrationAuthRailwayServices"] as const + }) => [{ integrationAuthId, appId }, "integrationAuthRailwayServices"] as const, + getIntegrationAuthBitBucketWorkspaces: (integrationAuthId: string) => + [{ integrationAuthId }, "integrationAuthTeams"] as const, }; const fetchIntegrationAuthById = async (integrationAuthId: string) => { @@ -44,12 +46,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 } @@ -127,6 +139,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), @@ -137,17 +156,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 }); @@ -224,6 +246,14 @@ 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 fd82d1fa75..6759c2207c 100644 --- a/frontend/src/views/IntegrationsPage/IntegrationPage.utils.tsx +++ b/frontend/src/views/IntegrationsPage/IntegrationPage.utils.tsx @@ -92,6 +92,9 @@ export const redirectForProviderAuth = (integrationOption: TCloudIntegration) => case "cloudflare-pages": link = `${window.location.origin}/integrations/cloudflare-pages/authorize`; break; + 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; default: break; } diff --git a/frontend/src/views/IntegrationsPage/components/IntegrationsSection/IntegrationsSection.tsx b/frontend/src/views/IntegrationsPage/components/IntegrationsSection/IntegrationsSection.tsx index 15ef381769..702e46ac92 100644 --- a/frontend/src/views/IntegrationsPage/components/IntegrationsSection/IntegrationsSection.tsx +++ b/frontend/src/views/IntegrationsPage/components/IntegrationsSection/IntegrationsSection.tsx @@ -106,7 +106,8 @@ export const IntegrationsSection = ({ {(integration.integration === "vercel" || integration.integration === "netlify" || integration.integration === "railway" || - integration.integration === "gitlab") && ( + integration.integration === "gitlab" || + integration.integration === "bitbucket") && (