diff --git a/backend/src/config/index.ts b/backend/src/config/index.ts index 3a3405e3d..b5e50d9da 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 getClientIdBitBucket = async () => (await client.getSecret("CLIENT_ID_BITBUCKET")).secretValue; +export const getClientIdGCPSecretManager = async () => (await client.getSecret("CLIENT_ID_GCP_SECRET_MANAGER")).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 getClientSecretBitBucket = async () => (await client.getSecret("CLIENT_SECRET_BITBUCKET")).secretValue; +export const getClientSecretGCPSecretManager = async () => (await client.getSecret("CLIENT_SECRET_GCP_SECRET_MANAGER")).secretValue; export const getClientSlugVercel = async () => (await client.getSecret("CLIENT_SLUG_VERCEL")).secretValue; export const getClientIdGoogleLogin = async () => (await client.getSecret("CLIENT_ID_GOOGLE_LOGIN")).secretValue; diff --git a/backend/src/integrations/apps.ts b/backend/src/integrations/apps.ts index 4598ab213..c9ddfc54a 100644 --- a/backend/src/integrations/apps.ts +++ b/backend/src/integrations/apps.ts @@ -1,4 +1,6 @@ import { + INTEGRATION_GCP_SECRET_MANAGER, + INTEGRATION_GCP_API_URL, INTEGRATION_AWS_PARAMETER_STORE, INTEGRATION_AWS_SECRET_MANAGER, INTEGRATION_AZURE_KEY_VAULT, @@ -79,6 +81,11 @@ const getApps = async ({ }) => { let apps: App[] = []; switch (integrationAuth.integration) { + case INTEGRATION_GCP_SECRET_MANAGER: + apps = await getAppsGCPSecretManager({ + accessToken, + }); + break; case INTEGRATION_AZURE_KEY_VAULT: apps = []; break; @@ -210,6 +217,91 @@ const getApps = async ({ return apps; }; +/** + * Return list of apps for Heroku integration + * @param {Object} obj + * @param {String} obj.accessToken - access token for Heroku API + * @returns {Object[]} apps - names of Heroku apps + * @returns {String} apps.name - name of Heroku app + */ +const getAppsGCPSecretManager = async ({ accessToken }: { accessToken: string }) => { + console.log("getAppsGCPSecretManager"); + console.log("getAppsGCPSecretManager accessToken: ", accessToken); + + let apps: any = []; + + interface GCPApp { + projectNumber: string; + projectId: string; + lifecycleState: "ACTIVE" | "LIFECYCLE_STATE_UNSPECIFIED" | "DELETE_REQUESTED" | "DELETE_IN_PROGRESS"; + name: string; + createTime: string; + parent: { + type: "organization" | "folder" | "project"; + id: string; + } + } + + interface GCPRes { + projects: GCPApp[]; + nextPageToken?: string; + } + + const pageSize = 10; + let pageToken: string | undefined; + let hasMorePages = true; + + while (hasMorePages) { + console.log("iterrr"); + const params = new URLSearchParams({ + pageSize: String(pageSize), + ...(pageToken ? { pageToken } : {}) + }); + console.log("params: ", params); + + const res: GCPRes = (await standardRequest.get(`${INTEGRATION_GCP_API_URL}/v1/projects`, { + params, + headers: { + "Authorization": `Bearer ${accessToken}`, + "Accept-Encoding": "application/json" + } + }) + ) + .data; + + res.projects.forEach((project) => { + apps.push({ + name: project.name, + appId: project.projectId + }); + }); + + if (!res.nextPageToken) { + hasMorePages = false; + } + + pageToken = res.nextPageToken; + } + + // const projects: GCPApp[] = ( + // .projects + + // console.log("res: ", res); + + // .filter((project: GCPApp) => project.lifecycleState === "ACTIVE"); + + // console.log("projects: ", projects); + + // const apps = projects.map((project) => ({ + // name: project.name, + // appId: project.projectId + // })); + + console.log("apps: ", apps); + + return []; +}; + /** * Return list of apps for Heroku integration * @param {Object} obj diff --git a/backend/src/integrations/exchange.ts b/backend/src/integrations/exchange.ts index dddbb65c6..5054df832 100644 --- a/backend/src/integrations/exchange.ts +++ b/backend/src/integrations/exchange.ts @@ -14,8 +14,12 @@ import { INTEGRATION_NETLIFY_TOKEN_URL, INTEGRATION_VERCEL, INTEGRATION_VERCEL_TOKEN_URL, + INTEGRATION_GCP_SECRET_MANAGER, + INTEGRATION_GCP_TOKEN_URL } from "../variables"; import { + getClientIdGCPSecretManager, + getClientSecretGCPSecretManager, getClientIdAzure, getClientIdBitBucket, getClientIdGitHub, @@ -113,6 +117,11 @@ const exchangeCode = async ({ let obj = {} as any; switch (integration) { + case INTEGRATION_GCP_SECRET_MANAGER: + obj = await exchangeCodeGCP({ + code, + }); + break; case INTEGRATION_AZURE_KEY_VAULT: obj = await exchangeCodeAzure({ code, @@ -153,6 +162,40 @@ const exchangeCode = async ({ return obj; }; +/** + * Return [accessToken] for GCP OAuth2 code-token exchange + * @param {Object} obj + * @param {String} obj.code - code for code-token exchange + * @returns {Object} obj2 + * @returns {String} obj2.accessToken - access token for GCP API + * @returns {String} obj2.refreshToken - refresh token for GCP API + * @returns {Date} obj2.accessExpiresAt - date of expiration for access token + */ +const exchangeCodeGCP = async ({ code }: { code: string }) => { + const accessExpiresAt = new Date(); + + const res: ExchangeCodeAzureResponse = ( + await standardRequest.post( + INTEGRATION_GCP_TOKEN_URL, + new URLSearchParams({ + grant_type: "authorization_code", + code: code, + client_id: await getClientIdGCPSecretManager(), + client_secret: await getClientSecretGCPSecretManager(), + redirect_uri: `${await getSiteURL()}/integrations/gcp-secret-manager/oauth2/callback`, + } as any) + ) + ).data; + + accessExpiresAt.setSeconds(accessExpiresAt.getSeconds() + res.expires_in); + + return { + accessToken: res.access_token, + refreshToken: res.refresh_token, + accessExpiresAt, + }; +}; + /** * Return [accessToken] for Azure OAuth2 code-token exchange * @param param0 diff --git a/backend/src/models/integration.ts b/backend/src/models/integration.ts index 579a7dbf2..6b22c51b8 100644 --- a/backend/src/models/integration.ts +++ b/backend/src/models/integration.ts @@ -24,7 +24,8 @@ import { INTEGRATION_TERRAFORM_CLOUD, INTEGRATION_TRAVISCI, INTEGRATION_VERCEL, - INTEGRATION_WINDMILL + INTEGRATION_WINDMILL, + INTEGRATION_GCP_SECRET_MANAGER } from "../variables"; import { Schema, Types, model } from "mongoose"; @@ -70,7 +71,8 @@ export interface IIntegration { | "digital-ocean-app-platform" | "cloud-66" | "northflank" - | "windmill"; + | "windmill" + | "gcp-secret-manager"; integrationAuth: Types.ObjectId; } @@ -167,7 +169,8 @@ const integrationSchema = new Schema( INTEGRATION_BITBUCKET, INTEGRATION_DIGITAL_OCEAN_APP_PLATFORM, INTEGRATION_CLOUD_66, - INTEGRATION_NORTHFLANK + INTEGRATION_NORTHFLANK, + INTEGRATION_GCP_SECRET_MANAGER ], required: true, }, diff --git a/backend/src/models/integrationAuth.ts b/backend/src/models/integrationAuth.ts index 66a26898f..1ad843f74 100644 --- a/backend/src/models/integrationAuth.ts +++ b/backend/src/models/integrationAuth.ts @@ -26,7 +26,8 @@ import { INTEGRATION_TERRAFORM_CLOUD, INTEGRATION_TRAVISCI, INTEGRATION_VERCEL, - INTEGRATION_WINDMILL + INTEGRATION_WINDMILL, + INTEGRATION_GCP_SECRET_MANAGER } from "../variables"; import { Document, Schema, Types, model } from "mongoose"; @@ -58,7 +59,8 @@ export interface IIntegrationAuth extends Document { | "terraform-cloud" | "teamcity" | "northflank" - | "windmill"; + | "windmill" + | "gcp-secret-manager"; teamId: string; accountId: string; url: string; @@ -111,7 +113,8 @@ const integrationAuthSchema = new Schema( INTEGRATION_BITBUCKET, INTEGRATION_DIGITAL_OCEAN_APP_PLATFORM, INTEGRATION_CLOUD_66, - INTEGRATION_NORTHFLANK + INTEGRATION_NORTHFLANK, + INTEGRATION_GCP_SECRET_MANAGER ], required: true, }, diff --git a/backend/src/variables/integration.ts b/backend/src/variables/integration.ts index b08d2a990..41c9f43a1 100644 --- a/backend/src/variables/integration.ts +++ b/backend/src/variables/integration.ts @@ -6,12 +6,14 @@ import { getClientIdHeroku, getClientIdNetlify, getClientSlugVercel, + getClientIdGCPSecretManager } from "../config"; // integrations export const INTEGRATION_AZURE_KEY_VAULT = "azure-key-vault"; export const INTEGRATION_AWS_PARAMETER_STORE = "aws-parameter-store"; export const INTEGRATION_AWS_SECRET_MANAGER = "aws-secret-manager"; +export const INTEGRATION_GCP_SECRET_MANAGER = "gcp-secret-manager"; export const INTEGRATION_HEROKU = "heroku"; export const INTEGRATION_VERCEL = "vercel"; export const INTEGRATION_NETLIFY = "netlify"; @@ -36,35 +38,37 @@ export const INTEGRATION_DIGITAL_OCEAN_APP_PLATFORM = "digital-ocean-app-platfor export const INTEGRATION_CLOUD_66 = "cloud-66"; export const INTEGRATION_NORTHFLANK = "northflank"; export const INTEGRATION_SET = new Set([ + INTEGRATION_GCP_SECRET_MANAGER, INTEGRATION_AZURE_KEY_VAULT, - INTEGRATION_HEROKU, - INTEGRATION_VERCEL, - INTEGRATION_NETLIFY, - INTEGRATION_GITHUB, - INTEGRATION_GITLAB, - INTEGRATION_RENDER, - INTEGRATION_FLYIO, - INTEGRATION_CIRCLECI, - INTEGRATION_LARAVELFORGE, - INTEGRATION_TRAVISCI, - INTEGRATION_TEAMCITY, - INTEGRATION_SUPABASE, - INTEGRATION_CHECKLY, - INTEGRATION_TERRAFORM_CLOUD, - INTEGRATION_HASHICORP_VAULT, - INTEGRATION_CLOUDFLARE_PAGES, - INTEGRATION_CODEFRESH, - INTEGRATION_WINDMILL, - INTEGRATION_BITBUCKET, - INTEGRATION_DIGITAL_OCEAN_APP_PLATFORM, - INTEGRATION_CLOUD_66, - INTEGRATION_NORTHFLANK + INTEGRATION_HEROKU, + INTEGRATION_VERCEL, + INTEGRATION_NETLIFY, + INTEGRATION_GITHUB, + INTEGRATION_GITLAB, + INTEGRATION_RENDER, + INTEGRATION_FLYIO, + INTEGRATION_CIRCLECI, + INTEGRATION_LARAVELFORGE, + INTEGRATION_TRAVISCI, + INTEGRATION_TEAMCITY, + INTEGRATION_SUPABASE, + INTEGRATION_CHECKLY, + INTEGRATION_TERRAFORM_CLOUD, + INTEGRATION_HASHICORP_VAULT, + INTEGRATION_CLOUDFLARE_PAGES, + INTEGRATION_CODEFRESH, + INTEGRATION_WINDMILL, + INTEGRATION_BITBUCKET, + INTEGRATION_DIGITAL_OCEAN_APP_PLATFORM, + INTEGRATION_CLOUD_66, + INTEGRATION_NORTHFLANK ]); // integration types export const INTEGRATION_OAUTH2 = "oauth2"; // integration oauth endpoints +export const INTEGRATION_GCP_TOKEN_URL = "https://accounts.google.com/o/oauth2/token"; export const INTEGRATION_AZURE_TOKEN_URL = "https://login.microsoftonline.com/common/oauth2/v2.0/token"; export const INTEGRATION_HEROKU_TOKEN_URL = "https://id.heroku.com/oauth/token"; export const INTEGRATION_VERCEL_TOKEN_URL = @@ -76,6 +80,7 @@ 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_GCP_API_URL = "https://cloudresourcemanager.googleapis.com"; export const INTEGRATION_HEROKU_API_URL = "https://api.heroku.com"; export const INTEGRATION_GITLAB_API_URL = "https://gitlab.com/api"; export const INTEGRATION_VERCEL_API_URL = "https://api.vercel.com"; @@ -272,12 +277,12 @@ export const getIntegrationOptions = async () => { docsLink: "", }, { - name: "Google Cloud Platform", - slug: "gcp", + name: "GCP Secret Manager", + slug: "gcp-secret-manager", image: "Google Cloud Platform.png", - isAvailable: false, - type: "", - clientId: "", + isAvailable: true, + type: "oauth", + clientId: await getClientIdGCPSecretManager(), docsLink: "" }, { diff --git a/frontend/public/data/frequentConstants.ts b/frontend/public/data/frequentConstants.ts index bcfaf1943..1cba174ca 100644 --- a/frontend/public/data/frequentConstants.ts +++ b/frontend/public/data/frequentConstants.ts @@ -6,29 +6,30 @@ 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", + "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", + "railway": "Railway", + "flyio": "Fly.io", + "circleci": "CircleCI", + "travisci": "TravisCI", + "supabase": "Supabase", + "checkly": "Checkly", "terraform-cloud": "Terraform Cloud", "teamcity": "TeamCity", "hashicorp-vault": "Vault", "cloudflare-pages": "Cloudflare Pages", "codefresh": "Codefresh", "digital-ocean-app-platform": "Digital Ocean App Platform", - bitbucket: "BitBucket", + "bitbucket": "BitBucket", "cloud-66": "Cloud 66", - northflank: "Northflank", - "windmill": "Windmill" + "northflank": "Northflank", + "windmill": "Windmill", + "gcp-secret-manager": "Google Cloud Platform" } const envMapping: Mapping = { diff --git a/frontend/src/pages/integrations/gcp-secret-manager/create.tsx b/frontend/src/pages/integrations/gcp-secret-manager/create.tsx new file mode 100644 index 000000000..975c05e96 --- /dev/null +++ b/frontend/src/pages/integrations/gcp-secret-manager/create.tsx @@ -0,0 +1,159 @@ +import { useEffect, useState } from "react"; +import { useRouter } from "next/router"; +import queryString from "query-string"; + +import { + useCreateIntegration +} from "@app/hooks/api"; + +import { + Button, + Card, + CardTitle, + FormControl, + Input, + Select, + SelectItem +} from "../../../components/v2"; +import { + useGetIntegrationAuthApps, + useGetIntegrationAuthById +} from "../../../hooks/api/integrationAuth"; +import { useGetWorkspaceById } from "../../../hooks/api/workspace"; + +export default function GCPSecretManagerCreateIntegrationPage() { + const router = useRouter(); + const { mutateAsync } = useCreateIntegration(); + + const { integrationAuthId } = queryString.parse(router.asPath.split("?")[1]); + + const { data: workspace } = useGetWorkspaceById(localStorage.getItem("projectData.id") ?? ""); + const { data: integrationAuth } = useGetIntegrationAuthById((integrationAuthId as string) ?? ""); + const { data: integrationAuthApps } = useGetIntegrationAuthApps({ + integrationAuthId: (integrationAuthId as string) ?? "" + }); + + console.log("integrationAuthApps: ", integrationAuthApps); + + const [selectedSourceEnvironment, setSelectedSourceEnvironment] = useState(""); + const [targetApp, setTargetApp] = useState(""); + const [secretPath, setSecretPath] = useState("/"); + + const [isLoading, setIsLoading] = useState(false); + + useEffect(() => { + if (workspace) { + setSelectedSourceEnvironment(workspace.environments[0].slug); + } + }, [workspace]); + + useEffect(() => { + if (integrationAuthApps) { + if (integrationAuthApps.length > 0) { + setTargetApp(integrationAuthApps[0].name); + } else { + setTargetApp("none"); + } + } + }, [integrationAuthApps]); + + const handleButtonClick = async () => { + try { + setIsLoading(true); + + if (!integrationAuth?._id) return; + + await mutateAsync({ + integrationAuthId: integrationAuth?._id, + isActive: true, + app: targetApp, + appId: null, + sourceEnvironment: selectedSourceEnvironment, + targetEnvironment: null, + targetEnvironmentId: null, + 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 && + targetApp ? ( +
+ + GCP Secret Manager Integration + {/* + + + + setSecretPath(evt.target.value)} + placeholder="Provide a path, default is /" + /> + + + + */} + + +
+ ) : ( +
+ ); +} + +GCPSecretManagerCreateIntegrationPage.requireAuth = true; diff --git a/frontend/src/pages/integrations/gcp-secret-manager/oauth2/callback.tsx b/frontend/src/pages/integrations/gcp-secret-manager/oauth2/callback.tsx new file mode 100644 index 000000000..cc620af8b --- /dev/null +++ b/frontend/src/pages/integrations/gcp-secret-manager/oauth2/callback.tsx @@ -0,0 +1,45 @@ +import { useEffect } from "react"; +import { useRouter } from "next/router"; +import queryString from "query-string"; + +import { + useAuthorizeIntegration +} from "@app/hooks/api"; + +export default function GCPSecretManagerOAuth2CallbackPage() { + console.log("GCPSecretManagerOAuth2CallbackPage"); + const router = useRouter(); + const { mutateAsync } = useAuthorizeIntegration(); + + const { code, state } = queryString.parse(router.asPath.split("?")[1]); + + useEffect(() => { + (async () => { + try { + // validate state + + console.log("gcp oauth2 callback page"); + console.log("gcp oauth2 callback page code: ", code); + console.log("gcp oauth2 callback page state: ", state); + + if (state !== localStorage.getItem("latestCSRFToken")) return; + localStorage.removeItem("latestCSRFToken"); + const integrationAuth = await mutateAsync({ + workspaceId: localStorage.getItem("projectData.id") as string, + code: code as string, + integration: "gcp-secret-manager" + }); + + console.log("integrationAuth: ", integrationAuth); + + router.push(`/integrations/gcp-secret-manager/create?integrationAuthId=${integrationAuth._id}`); + } catch (err) { + console.error(err); + } + })(); + }, []); + + return
; +} + +GCPSecretManagerOAuth2CallbackPage.requireAuth = true; diff --git a/frontend/src/views/IntegrationsPage/IntegrationPage.utils.tsx b/frontend/src/views/IntegrationsPage/IntegrationPage.utils.tsx index e267c13f5..bff23753a 100644 --- a/frontend/src/views/IntegrationsPage/IntegrationPage.utils.tsx +++ b/frontend/src/views/IntegrationsPage/IntegrationPage.utils.tsx @@ -32,12 +32,16 @@ export const generateBotKey = (botPublicKey: string, latestKey: UserWsKeyPair) = export const redirectForProviderAuth = (integrationOption: TCloudIntegration) => { try { + // generate CSRF token for OAuth2 code-token exchange integrations const state = crypto.randomBytes(16).toString("hex"); localStorage.setItem("latestCSRFToken", state); let link = ""; switch (integrationOption.slug) { + case "gcp-secret-manager": + link = `https://accounts.google.com/o/oauth2/auth?scope=https://www.googleapis.com/auth/cloud-platform&response_type=code&access_type=offline&state=${state}&redirect_uri=${window.location.origin}/integrations/gcp-secret-manager/oauth2/callback&client_id=${integrationOption.clientId}`; + break; case "azure-key-vault": link = `https://login.microsoftonline.com/common/oauth2/v2.0/authorize?client_id=${integrationOption.clientId}&response_type=code&redirect_uri=${window.location.origin}/integrations/azure-key-vault/oauth2/callback&response_mode=query&scope=https://vault.azure.net/.default openid offline_access&state=${state}`; break; @@ -123,6 +127,7 @@ export const redirectForProviderAuth = (integrationOption: TCloudIntegration) => if (link !== "") { window.location.assign(link); } + } catch (err) { console.error(err); }