diff --git a/backend/src/controllers/v1/integrationAuthController.ts b/backend/src/controllers/v1/integrationAuthController.ts index b91319a2f1..8d7170b01d 100644 --- a/backend/src/controllers/v1/integrationAuthController.ts +++ b/backend/src/controllers/v1/integrationAuthController.ts @@ -14,8 +14,10 @@ import { INTEGRATION_RAILWAY_API_URL, INTEGRATION_SET, INTEGRATION_VERCEL_API_URL, + INTEGRATION_GCP_SECRET_MANAGER, getIntegrationOptions as getIntegrationOptionsFunc } from "../../variables"; +import { exchangeRefresh } from "../../integrations"; /*** * Return integration authorization with id [integrationAuthId] @@ -88,7 +90,7 @@ export const oAuthExchange = async (req: Request, res: Response) => { * @param req * @param res */ -export const saveIntegrationAccessToken = async (req: Request, res: Response) => { +export const saveIntegrationToken = async (req: Request, res: Response) => { // TODO: refactor // TODO: check if access token is valid for each integration @@ -96,14 +98,16 @@ export const saveIntegrationAccessToken = async (req: Request, res: Response) => const { workspaceId, accessId, + refreshToken, accessToken, url, namespace, integration }: { workspaceId: string; - accessId: string | null; - accessToken: string; + accessId: string | undefined; + refreshToken: string | undefined; + accessToken: string | undefined; url: string; namespace: string; integration: string; @@ -127,21 +131,36 @@ export const saveIntegrationAccessToken = async (req: Request, res: Response) => url, namespace, algorithm: ALGORITHM_AES_256_GCM, - keyEncoding: ENCODING_SCHEME_UTF8 + keyEncoding: ENCODING_SCHEME_UTF8, + ...(integration === INTEGRATION_GCP_SECRET_MANAGER ? { + metadata: { + authMethod: "serviceAccount" + } + } : {}) }, { new: true, upsert: true } ); + + // encrypt and save integration access details + if (refreshToken) { + await exchangeRefresh({ + integrationAuth, + refreshToken + }); + } // encrypt and save integration access details - integrationAuth = await IntegrationService.setIntegrationAuthAccess({ - integrationAuthId: integrationAuth._id.toString(), - accessId, - accessToken, - accessExpiresAt: undefined - }); + if (accessId || accessToken) { + integrationAuth = await IntegrationService.setIntegrationAuthAccess({ + integrationAuthId: integrationAuth._id.toString(), + accessId, + accessToken, + accessExpiresAt: undefined + }); + } if (!integrationAuth) throw new Error("Failed to save integration access token"); diff --git a/backend/src/helpers/integration.ts b/backend/src/helpers/integration.ts index 4b1604d500..6a948a0395 100644 --- a/backend/src/helpers/integration.ts +++ b/backend/src/helpers/integration.ts @@ -1,20 +1,23 @@ import { Types } from "mongoose"; -import { Bot, IntegrationAuth } from "../models"; +import { Bot, IIntegrationAuth, IntegrationAuth } from "../models"; import { exchangeCode, exchangeRefresh } from "../integrations"; import { BotService } from "../services"; import { ALGORITHM_AES_256_GCM, ENCODING_SCHEME_UTF8, INTEGRATION_NETLIFY, - INTEGRATION_VERCEL + INTEGRATION_VERCEL, + INTEGRATION_GCP_SECRET_MANAGER, } from "../variables"; -import { UnauthorizedRequestError } from "../utils/errors"; +import { BadRequestError, InternalServerError, UnauthorizedRequestError } from "../utils/errors"; +import { IntegrationAuthMetadata } from "../models/integrationAuth/types"; interface Update { workspace: string; integration: string; teamId?: string; accountId?: string; + metadata?: IntegrationAuthMetadata } /** @@ -64,6 +67,10 @@ export const handleOAuthExchangeHelper = async ({ break; case INTEGRATION_NETLIFY: update.accountId = res.accountId; + case INTEGRATION_GCP_SECRET_MANAGER: + update.metadata = { + authMethod: "oauth2" + } break; } @@ -93,7 +100,6 @@ export const handleOAuthExchangeHelper = async ({ // set integration auth access token await setIntegrationAuthAccessHelper({ integrationAuthId: integrationAuth._id.toString(), - accessId: null, accessToken: res.accessToken, accessExpiresAt: res.accessExpiresAt }); @@ -158,22 +164,24 @@ export const getIntegrationAuthAccessHelper = async ({ message: "Failed to locate Integration Authentication credentials" }); - accessToken = await BotService.decryptSymmetric({ - workspaceId: integrationAuth.workspace, - ciphertext: integrationAuth.accessCiphertext as string, - iv: integrationAuth.accessIV as string, - tag: integrationAuth.accessTag as string - }); + if (integrationAuth.accessCiphertext && integrationAuth.accessIV && integrationAuth.accessTag) { + accessToken = await BotService.decryptSymmetric({ + workspaceId: integrationAuth.workspace, + ciphertext: integrationAuth.accessCiphertext as string, + iv: integrationAuth.accessIV as string, + tag: integrationAuth.accessTag as string + }); + } - if (integrationAuth?.accessExpiresAt && integrationAuth?.refreshCiphertext) { + if (integrationAuth?.refreshCiphertext) { // there is a access token expiration date // and refresh token to exchange with the OAuth2 server + const refreshToken = await getIntegrationAuthRefreshHelper({ + integrationAuthId + }); - if (integrationAuth.accessExpiresAt < new Date()) { + if (integrationAuth?.accessExpiresAt && integrationAuth.accessExpiresAt < new Date()) { // access token is expired - const refreshToken = await getIntegrationAuthRefreshHelper({ - integrationAuthId - }); accessToken = await exchangeRefresh({ integrationAuth, refreshToken @@ -194,6 +202,8 @@ export const getIntegrationAuthAccessHelper = async ({ }); } + if (!accessToken) throw InternalServerError(); + return { accessId, accessToken @@ -214,7 +224,7 @@ export const setIntegrationAuthRefreshHelper = async ({ }: { integrationAuthId: string; refreshToken: string; -}) => { +}): Promise => { let integrationAuth = await IntegrationAuth.findById(integrationAuthId); if (!integrationAuth) throw new Error("Failed to find integration auth"); @@ -239,6 +249,8 @@ export const setIntegrationAuthRefreshHelper = async ({ new: true } ); + + if (!integrationAuth) throw InternalServerError(); return integrationAuth; }; @@ -259,20 +271,24 @@ export const setIntegrationAuthAccessHelper = async ({ accessExpiresAt }: { integrationAuthId: string; - accessId: string | null; - accessToken: string; + accessId?: string; + accessToken?: string; accessExpiresAt: Date | undefined; }) => { let integrationAuth = await IntegrationAuth.findById(integrationAuthId); if (!integrationAuth) throw new Error("Failed to find integration auth"); - - const encryptedAccessTokenObj = await BotService.encryptSymmetric({ - workspaceId: integrationAuth.workspace, - plaintext: accessToken - }); - + + let encryptedAccessTokenObj; let encryptedAccessIdObj; + + if (accessToken) { + encryptedAccessTokenObj = await BotService.encryptSymmetric({ + workspaceId: integrationAuth.workspace, + plaintext: accessToken + }); + } + if (accessId) { encryptedAccessIdObj = await BotService.encryptSymmetric({ workspaceId: integrationAuth.workspace, @@ -286,11 +302,11 @@ export const setIntegrationAuthAccessHelper = async ({ }, { accessIdCiphertext: encryptedAccessIdObj?.ciphertext ?? undefined, - accessIdIV: encryptedAccessIdObj?.iv ?? undefined, - accessIdTag: encryptedAccessIdObj?.tag ?? undefined, - accessCiphertext: encryptedAccessTokenObj.ciphertext, - accessIV: encryptedAccessTokenObj.iv, - accessTag: encryptedAccessTokenObj.tag, + accessIdIV: encryptedAccessIdObj?.iv, + accessIdTag: encryptedAccessIdObj?.tag, + accessCiphertext: encryptedAccessTokenObj?.ciphertext, + accessIV: encryptedAccessTokenObj?.iv, + accessTag: encryptedAccessTokenObj?.tag, accessExpiresAt, algorithm: ALGORITHM_AES_256_GCM, keyEncoding: ENCODING_SCHEME_UTF8 diff --git a/backend/src/integrations/exchange.ts b/backend/src/integrations/exchange.ts index 3201e6ca17..6412404701 100644 --- a/backend/src/integrations/exchange.ts +++ b/backend/src/integrations/exchange.ts @@ -46,6 +46,14 @@ interface ExchangeCodeAzureResponse { id_token: string; } +interface ExchangeCodeGCPResponse { + access_token: string; + expires_in: number; + refresh_token: string; + scope: string; + token_type: string; +} + interface ExchangeCodeHerokuResponse { token_type: string; access_token: string; @@ -174,7 +182,7 @@ const exchangeCode = async ({ const exchangeCodeGCP = async ({ code }: { code: string }) => { const accessExpiresAt = new Date(); - const res: ExchangeCodeAzureResponse = ( + const res: ExchangeCodeGCPResponse = ( await standardRequest.post( INTEGRATION_GCP_TOKEN_URL, new URLSearchParams({ diff --git a/backend/src/integrations/refresh.ts b/backend/src/integrations/refresh.ts index 426d414f5a..36cc4b9302 100644 --- a/backend/src/integrations/refresh.ts +++ b/backend/src/integrations/refresh.ts @@ -1,3 +1,4 @@ +import jwt from "jsonwebtoken"; import { standardRequest } from "../config/request"; import { IIntegrationAuth } from "../models"; import { @@ -6,6 +7,9 @@ import { INTEGRATION_BITBUCKET_TOKEN_URL, INTEGRATION_GITLAB, INTEGRATION_HEROKU, + INTEGRATION_GCP_SECRET_MANAGER, + INTEGRATION_GCP_TOKEN_URL, + INTEGRATION_GCP_CLOUD_PLATFORM_SCOPE } from "../variables"; import { INTEGRATION_AZURE_TOKEN_URL, @@ -21,6 +25,8 @@ import { getClientSecretBitBucket, getClientSecretGitLab, getClientSecretHeroku, + getClientIdGCPSecretManager, + getClientSecretGCPSecretManager, getSiteURL, } from "../config"; @@ -59,6 +65,19 @@ interface RefreshTokenBitBucketResponse { state: string; } +interface ServiceAccountAccessTokenGCPSecretManagerResponse { + access_token: string; + expires_in: number; + token_type: string; +} + +interface RefreshTokenGCPSecretManagerResponse { + access_token: string; + expires_in: number; + scope: string; + token_type: string; +} + /** * Return new access token by exchanging refresh token [refreshToken] for integration * named [integration] @@ -101,18 +120,23 @@ const exchangeRefresh = async ({ refreshToken, }); break; + case INTEGRATION_GCP_SECRET_MANAGER: + tokenDetails = await exchangeRefreshGCPSecretManager({ + integrationAuth, + refreshToken, + }); + break; default: throw new Error("Failed to exchange token for incompatible integration"); } if ( - tokenDetails?.accessToken && - tokenDetails?.refreshToken && - tokenDetails?.accessExpiresAt + tokenDetails.accessToken && + tokenDetails.refreshToken && + tokenDetails.accessExpiresAt ) { await IntegrationService.setIntegrationAuthAccess({ integrationAuthId: integrationAuth._id.toString(), - accessId: null, accessToken: tokenDetails.accessToken, accessExpiresAt: tokenDetails.accessExpiresAt, }); @@ -278,4 +302,76 @@ const exchangeRefreshBitBucket = async ({ }; }; -export { exchangeRefresh }; +/** + * Return new access token by exchanging refresh token [refreshToken] for the + * GCP Secret Manager integration + * @param {Object} obj + * @param {String} obj.refreshToken - refresh token to use to get new access token for GCP Secret Manager + * @returns + */ +const exchangeRefreshGCPSecretManager = async ({ + integrationAuth, + refreshToken, +}: { + integrationAuth: IIntegrationAuth; + refreshToken: string; +}) => { + const accessExpiresAt = new Date(); + + if (integrationAuth.metadata?.authMethod === "serviceAccount") { + const serviceAccount = JSON.parse(refreshToken); + + const payload = { + iss: serviceAccount.client_email, + aud: serviceAccount.token_uri, + scope: INTEGRATION_GCP_CLOUD_PLATFORM_SCOPE, + iat: Math.floor(Date.now() / 1000), + exp: Math.floor(Date.now() / 1000) + 3600, + }; + + const token = jwt.sign(payload, serviceAccount.private_key, { algorithm: 'RS256' }); + + const { data }: { data: ServiceAccountAccessTokenGCPSecretManagerResponse } = await standardRequest.post( + INTEGRATION_GCP_TOKEN_URL, + new URLSearchParams({ + grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer', + assertion: token + }).toString(), + { + headers: { + 'Content-Type': 'application/x-www-form-urlencoded' + } + } + ); + + accessExpiresAt.setSeconds(accessExpiresAt.getSeconds() + data.expires_in); + + return { + accessToken: data.access_token, + refreshToken, + accessExpiresAt + }; + } + + const { data }: { data: RefreshTokenGCPSecretManagerResponse } = ( + await standardRequest.post( + INTEGRATION_GCP_TOKEN_URL, + new URLSearchParams({ + client_id: await getClientIdGCPSecretManager(), + client_secret: await getClientSecretGCPSecretManager(), + refresh_token: refreshToken, + grant_type: "refresh_token", + } as any) + ) + ); + + accessExpiresAt.setSeconds(accessExpiresAt.getSeconds() + data.expires_in); + + return { + accessToken: data.access_token, + refreshToken, + accessExpiresAt, + }; +}; + +export { exchangeRefresh }; \ No newline at end of file diff --git a/backend/src/integrations/sync.ts b/backend/src/integrations/sync.ts index 2867ad44f9..ff8f43eccb 100644 --- a/backend/src/integrations/sync.ts +++ b/backend/src/integrations/sync.ts @@ -1,3 +1,4 @@ +import jwt from "jsonwebtoken"; import { CreateSecretCommand, GetSecretValueCommand, @@ -28,6 +29,8 @@ import { INTEGRATION_FLYIO_API_URL, INTEGRATION_GCP_SECRET_MANAGER, INTEGRATION_GCP_SECRET_MANAGER_URL, + INTEGRATION_GCP_TOKEN_URL, + INTEGRATION_GCP_CLOUD_PLATFORM_SCOPE, INTEGRATION_GITHUB, INTEGRATION_GITLAB, INTEGRATION_GITLAB_API_URL, diff --git a/backend/src/models/integrationAuth.ts b/backend/src/models/integrationAuth.ts deleted file mode 100644 index 5dcd3dfe85..0000000000 --- a/backend/src/models/integrationAuth.ts +++ /dev/null @@ -1,199 +0,0 @@ -import { - ALGORITHM_AES_256_GCM, - ENCODING_SCHEME_BASE64, - ENCODING_SCHEME_UTF8, - INTEGRATION_AWS_PARAMETER_STORE, - INTEGRATION_AWS_SECRET_MANAGER, - INTEGRATION_AZURE_KEY_VAULT, - INTEGRATION_BITBUCKET, - INTEGRATION_CIRCLECI, - INTEGRATION_CLOUDFLARE_PAGES, - INTEGRATION_CLOUD_66, - INTEGRATION_CODEFRESH, - INTEGRATION_DIGITAL_OCEAN_APP_PLATFORM, - INTEGRATION_FLYIO, - INTEGRATION_GCP_SECRET_MANAGER, - INTEGRATION_GITHUB, - INTEGRATION_GITLAB, - INTEGRATION_HASHICORP_VAULT, - INTEGRATION_HEROKU, - INTEGRATION_LARAVELFORGE, - INTEGRATION_NETLIFY, - INTEGRATION_NORTHFLANK, - INTEGRATION_RAILWAY, - INTEGRATION_RENDER, - INTEGRATION_SUPABASE, - INTEGRATION_TEAMCITY, - INTEGRATION_TERRAFORM_CLOUD, - INTEGRATION_TRAVISCI, - INTEGRATION_VERCEL, - INTEGRATION_WINDMILL -} from "../variables"; -import { Document, Schema, Types, model } from "mongoose"; - -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" - | "digital-ocean-app-platform" - | "bitbucket" - | "cloud-66" - | "terraform-cloud" - | "teamcity" - | "northflank" - | "windmill" - | "gcp-secret-manager"; - teamId: string; - accountId: string; - url: string; - namespace: string; - refreshCiphertext?: string; - refreshIV?: string; - refreshTag?: string; - accessIdCiphertext?: string; - accessIdIV?: string; - accessIdTag?: string; - accessCiphertext?: string; - accessIV?: string; - accessTag?: string; - algorithm?: "aes-256-gcm"; - keyEncoding?: "utf8" | "base64"; - accessExpiresAt?: Date; -} - -const integrationAuthSchema = new Schema( - { - workspace: { - type: Schema.Types.ObjectId, - ref: "Workspace", - required: true, - }, - integration: { - type: String, - enum: [ - INTEGRATION_AZURE_KEY_VAULT, - INTEGRATION_AWS_PARAMETER_STORE, - INTEGRATION_AWS_SECRET_MANAGER, - INTEGRATION_HEROKU, - INTEGRATION_VERCEL, - INTEGRATION_NETLIFY, - INTEGRATION_GITHUB, - INTEGRATION_GITLAB, - INTEGRATION_RENDER, - INTEGRATION_RAILWAY, - INTEGRATION_FLYIO, - INTEGRATION_CIRCLECI, - INTEGRATION_LARAVELFORGE, - INTEGRATION_TRAVISCI, - INTEGRATION_TEAMCITY, - INTEGRATION_SUPABASE, - 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_GCP_SECRET_MANAGER - ], - required: true, - }, - teamId: { - // vercel-specific integration param - type: String, - }, - url: { - // for any self-hosted integrations (e.g. self-hosted hashicorp-vault) - type: String, - }, - namespace: { - // hashicorp-vault-specific integration param - type: String, - }, - accountId: { - // netlify-specific integration param - type: String, - }, - refreshCiphertext: { - type: String, - select: false, - }, - refreshIV: { - type: String, - select: false, - }, - refreshTag: { - type: String, - select: false, - }, - accessIdCiphertext: { - type: String, - select: false, - }, - accessIdIV: { - type: String, - select: false, - }, - accessIdTag: { - type: String, - select: false, - }, - accessCiphertext: { - type: String, - select: false, - }, - accessIV: { - type: String, - select: false, - }, - accessTag: { - type: String, - select: false, - }, - accessExpiresAt: { - type: Date, - select: false, - }, - algorithm: { // the encryption algorithm used - type: String, - enum: [ALGORITHM_AES_256_GCM], - required: true, - }, - keyEncoding: { - type: String, - enum: [ - ENCODING_SCHEME_UTF8, - ENCODING_SCHEME_BASE64, - ], - required: true, - }, - }, - { - timestamps: true, - } -); - -export const IntegrationAuth = model( - "IntegrationAuth", - integrationAuthSchema -); \ No newline at end of file diff --git a/backend/src/models/integrationAuth/index.ts b/backend/src/models/integrationAuth/index.ts new file mode 100644 index 0000000000..157095bd28 --- /dev/null +++ b/backend/src/models/integrationAuth/index.ts @@ -0,0 +1 @@ +export * from "./integrationAuth"; \ No newline at end of file diff --git a/backend/src/models/integrationAuth/integrationAuth.ts b/backend/src/models/integrationAuth/integrationAuth.ts new file mode 100644 index 0000000000..299ca9abe2 --- /dev/null +++ b/backend/src/models/integrationAuth/integrationAuth.ts @@ -0,0 +1,204 @@ +import { + ALGORITHM_AES_256_GCM, + ENCODING_SCHEME_BASE64, + ENCODING_SCHEME_UTF8, + INTEGRATION_AWS_PARAMETER_STORE, + INTEGRATION_AWS_SECRET_MANAGER, + INTEGRATION_AZURE_KEY_VAULT, + INTEGRATION_BITBUCKET, + INTEGRATION_CIRCLECI, + INTEGRATION_CLOUDFLARE_PAGES, + INTEGRATION_CLOUD_66, + INTEGRATION_CODEFRESH, + INTEGRATION_DIGITAL_OCEAN_APP_PLATFORM, + INTEGRATION_FLYIO, + INTEGRATION_GCP_SECRET_MANAGER, + INTEGRATION_GITHUB, + INTEGRATION_GITLAB, + INTEGRATION_HASHICORP_VAULT, + INTEGRATION_HEROKU, + INTEGRATION_LARAVELFORGE, + INTEGRATION_NETLIFY, + INTEGRATION_NORTHFLANK, + INTEGRATION_RAILWAY, + INTEGRATION_RENDER, + INTEGRATION_SUPABASE, + INTEGRATION_TEAMCITY, + INTEGRATION_TERRAFORM_CLOUD, + INTEGRATION_TRAVISCI, + INTEGRATION_VERCEL, + INTEGRATION_WINDMILL + } from "../../variables"; + import { Document, Schema, Types, model } from "mongoose"; + import { IntegrationAuthMetadata } from "./types"; + + 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" + | "digital-ocean-app-platform" + | "bitbucket" + | "cloud-66" + | "terraform-cloud" + | "teamcity" + | "northflank" + | "windmill" + | "gcp-secret-manager"; + teamId: string; + accountId: string; + url: string; + namespace: string; + refreshCiphertext?: string; + refreshIV?: string; + refreshTag?: string; + accessIdCiphertext?: string; + accessIdIV?: string; + accessIdTag?: string; + accessCiphertext?: string; + accessIV?: string; + accessTag?: string; + algorithm?: "aes-256-gcm"; + keyEncoding?: "utf8" | "base64"; + accessExpiresAt?: Date; + metadata?: IntegrationAuthMetadata; + } + + const integrationAuthSchema = new Schema( + { + workspace: { + type: Schema.Types.ObjectId, + ref: "Workspace", + required: true, + }, + integration: { + type: String, + enum: [ + INTEGRATION_AZURE_KEY_VAULT, + INTEGRATION_AWS_PARAMETER_STORE, + INTEGRATION_AWS_SECRET_MANAGER, + INTEGRATION_HEROKU, + INTEGRATION_VERCEL, + INTEGRATION_NETLIFY, + INTEGRATION_GITHUB, + INTEGRATION_GITLAB, + INTEGRATION_RENDER, + INTEGRATION_RAILWAY, + INTEGRATION_FLYIO, + INTEGRATION_CIRCLECI, + INTEGRATION_LARAVELFORGE, + INTEGRATION_TRAVISCI, + INTEGRATION_TEAMCITY, + INTEGRATION_SUPABASE, + 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_GCP_SECRET_MANAGER + ], + required: true, + }, + teamId: { + // vercel-specific integration param + type: String, + }, + url: { + // for any self-hosted integrations (e.g. self-hosted hashicorp-vault) + type: String, + }, + namespace: { + // hashicorp-vault-specific integration param + type: String, + }, + accountId: { + // netlify-specific integration param + type: String, + }, + refreshCiphertext: { + type: String, + select: false, + }, + refreshIV: { + type: String, + select: false, + }, + refreshTag: { + type: String, + select: false, + }, + accessIdCiphertext: { + type: String, + select: false, + }, + accessIdIV: { + type: String, + select: false, + }, + accessIdTag: { + type: String, + select: false, + }, + accessCiphertext: { + type: String, + select: false, + }, + accessIV: { + type: String, + select: false, + }, + accessTag: { + type: String, + select: false, + }, + accessExpiresAt: { + type: Date, + select: false, + }, + algorithm: { // the encryption algorithm used + type: String, + enum: [ALGORITHM_AES_256_GCM], + required: true, + }, + keyEncoding: { + type: String, + enum: [ + ENCODING_SCHEME_UTF8, + ENCODING_SCHEME_BASE64, + ], + required: true, + }, + metadata: { + type: Schema.Types.Mixed + } + }, + { + timestamps: true, + } + ); + + export const IntegrationAuth = model( + "IntegrationAuth", + integrationAuthSchema + ); \ No newline at end of file diff --git a/backend/src/models/integrationAuth/types.ts b/backend/src/models/integrationAuth/types.ts new file mode 100644 index 0000000000..d29869e3b3 --- /dev/null +++ b/backend/src/models/integrationAuth/types.ts @@ -0,0 +1,5 @@ +interface GCPIntegrationAuthMetadata { + authMethod: "oauth2" | "serviceAccount" +} + +export type IntegrationAuthMetadata = GCPIntegrationAuthMetadata; \ No newline at end of file diff --git a/backend/src/queues/integrations/syncSecretsToThirdPartyServices.ts b/backend/src/queues/integrations/syncSecretsToThirdPartyServices.ts index 3b5e04a3ca..d3ffef6e6f 100644 --- a/backend/src/queues/integrations/syncSecretsToThirdPartyServices.ts +++ b/backend/src/queues/integrations/syncSecretsToThirdPartyServices.ts @@ -22,7 +22,6 @@ syncSecretsToThirdPartyServices.process(async (job: Job) => { } : {}), isActive: true, - app: { $ne: null } }); // for each workspace integration, sync/push secrets diff --git a/backend/src/routes/v1/integration.ts b/backend/src/routes/v1/integration.ts index a5d32349e3..22aa901ea9 100644 --- a/backend/src/routes/v1/integration.ts +++ b/backend/src/routes/v1/integration.ts @@ -25,9 +25,9 @@ router.post( location: "body", }), body("integrationAuthId").exists().isString().trim(), - body("app").trim(), body("isActive").exists().isBoolean(), - body("appId").trim(), + body("app").optional().isString().trim(), + body("appId").optional().isString().trim(), body("secretPath").default("/").isString().trim(), body("sourceEnvironment").trim(), body("targetEnvironment").trim(), diff --git a/backend/src/routes/v1/integrationAuth.ts b/backend/src/routes/v1/integrationAuth.ts index edc7314ccb..caa9020725 100644 --- a/backend/src/routes/v1/integrationAuth.ts +++ b/backend/src/routes/v1/integrationAuth.ts @@ -29,6 +29,7 @@ router.get( }), requireIntegrationAuthorizationAuth({ acceptedRoles: [ADMIN, MEMBER], + attachAccessToken: false }), param("integrationAuthId"), validateRequest, @@ -54,8 +55,9 @@ router.post( router.post( "/access-token", body("workspaceId").exists().trim().notEmpty(), - body("accessId").trim(), - body("accessToken").exists().trim().notEmpty(), + body("refreshToken").optional().isString().trim().notEmpty(), + body("accessId").optional().isString().trim(), + body("accessToken").optional().isString().trim().notEmpty(), body("url").trim(), body("namespace").trim(), body("integration").exists().trim().notEmpty(), @@ -67,7 +69,7 @@ router.post( acceptedRoles: [ADMIN, MEMBER], locationWorkspaceId: "body", }), - integrationAuthController.saveIntegrationAccessToken + integrationAuthController.saveIntegrationToken ); router.get( diff --git a/backend/src/services/IntegrationService.ts b/backend/src/services/IntegrationService.ts index 9b28bd07d1..9523058a09 100644 --- a/backend/src/services/IntegrationService.ts +++ b/backend/src/services/IntegrationService.ts @@ -7,6 +7,7 @@ import { setIntegrationAuthRefreshHelper, } from "../helpers/integration"; import { syncSecretsToActiveIntegrationsQueue } from "../queues/integrations/syncSecretsToThirdPartyServices"; +import { IIntegrationAuth } from "../models"; /** * Class to handle integrations @@ -102,7 +103,7 @@ class IntegrationService { }: { integrationAuthId: string; refreshToken: string; - }) { + }): Promise { return await setIntegrationAuthRefreshHelper({ integrationAuthId, refreshToken, @@ -127,8 +128,8 @@ class IntegrationService { accessExpiresAt, }: { integrationAuthId: string; - accessId: string | null; - accessToken: string; + accessId?: string; + accessToken?: string; accessExpiresAt: Date | undefined; }) { return await setIntegrationAuthAccessHelper({ diff --git a/backend/src/variables/integration.ts b/backend/src/variables/integration.ts index ed69cf5fca..fe04b6401b 100644 --- a/backend/src/variables/integration.ts +++ b/backend/src/variables/integration.ts @@ -68,7 +68,7 @@ export const INTEGRATION_SET = new Set([ export const INTEGRATION_OAUTH2 = "oauth2"; // integration oauth endpoints -export const INTEGRATION_GCP_TOKEN_URL = "https://accounts.google.com/o/oauth2/token"; +export const INTEGRATION_GCP_TOKEN_URL = "https://oauth2.googleapis.com/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 = @@ -105,6 +105,7 @@ export const INTEGRATION_NORTHFLANK_API_URL = "https://api.northflank.com"; export const INTEGRATION_GCP_SECRET_MANAGER_SERVICE_NAME = "secretmanager.googleapis.com" export const INTEGRATION_GCP_SECRET_MANAGER_URL = `https://${INTEGRATION_GCP_SECRET_MANAGER_SERVICE_NAME}`; export const INTEGRATION_GCP_SERVICE_USAGE_URL = "https://serviceusage.googleapis.com"; +export const INTEGRATION_GCP_CLOUD_PLATFORM_SCOPE = "https://www.googleapis.com/auth/cloud-platform"; export const getIntegrationOptions = async () => { const INTEGRATION_OPTIONS = [ diff --git a/docs/images/integrations/gcp-secret-manager/integrations-gcp-secret-manager-auth-options.png b/docs/images/integrations/gcp-secret-manager/integrations-gcp-secret-manager-auth-options.png new file mode 100644 index 0000000000..9b2f665a14 Binary files /dev/null and b/docs/images/integrations/gcp-secret-manager/integrations-gcp-secret-manager-auth-options.png differ diff --git a/docs/images/integrations/gcp-secret-manager/integrations-gcp-secret-manager-iam-key.png b/docs/images/integrations/gcp-secret-manager/integrations-gcp-secret-manager-iam-key.png new file mode 100644 index 0000000000..242a8ae352 Binary files /dev/null and b/docs/images/integrations/gcp-secret-manager/integrations-gcp-secret-manager-iam-key.png differ diff --git a/docs/images/integrations/gcp-secret-manager/integrations-gcp-secret-manager-iam.png b/docs/images/integrations/gcp-secret-manager/integrations-gcp-secret-manager-iam.png new file mode 100644 index 0000000000..35af1251c2 Binary files /dev/null and b/docs/images/integrations/gcp-secret-manager/integrations-gcp-secret-manager-iam.png differ diff --git a/docs/integrations/cloud/aws-parameter-store.mdx b/docs/integrations/cloud/aws-parameter-store.mdx index 63fd1e1c69..42b41ac739 100644 --- a/docs/integrations/cloud/aws-parameter-store.mdx +++ b/docs/integrations/cloud/aws-parameter-store.mdx @@ -16,7 +16,7 @@ Navigate to your IAM user permissions and add a permission policy to grant acces ![integration IAM 2](../../images/integrations-aws-parameter-store-iam-2.png) ![integrations IAM 3](../../images/integrations-aws-parameter-store-iam-3.png) -For better security, here's a custom policy containing the minimum permissions required by Infisical to sync secrets to AWS Parameter Store for the IAM user that you can use: +For enhanced security, here's a custom policy containing the minimum permissions required by Infisical to sync secrets to AWS Parameter Store for the IAM user that you can use: ```json { diff --git a/docs/integrations/cloud/gcp-secret-manager.mdx b/docs/integrations/cloud/gcp-secret-manager.mdx index 6033f7e649..beead95790 100644 --- a/docs/integrations/cloud/gcp-secret-manager.mdx +++ b/docs/integrations/cloud/gcp-secret-manager.mdx @@ -5,17 +5,24 @@ description: "How to sync secrets from Infisical to GCP Secret Manager" + + + + Prerequisites: - Set up and add envars to [Infisical Cloud](https://app.infisical.com) - -## Navigate to your project's integrations tab + ## Navigate to your project's integrations tab ![integrations](../../images/integrations.png) ## Authorize Infisical for GCP -Press on the GCP Secret Manager tile and grant Infisical access to GCP. +Press on the GCP Secret Manager tile and select **Continue with OAuth** + +![integrations GCP authorization options](../../images/integrations/gcp-secret-manager/integrations-gcp-secret-manager-auth-options.png) + +Grant Infisical access to GCP. ![integrations GCP authorization](../../images/integrations/gcp-secret-manager/integrations-gcp-secret-manager-auth.png) @@ -37,9 +44,61 @@ Select which Infisical environment secrets you want to sync to which GCP secret Using Infisical to sync secrets to GCP Secret Manager requires that you enable the Service Usage API in the Google Cloud project you want to sync secrets to. More on that [here](https://cloud.google.com/service-usage/docs/set-up-development-environment). + + + Prerequisites: + +- Set up and add envars to [Infisical Cloud](https://app.infisical.com) +- Have a GCP project and have/create a [service account](https://cloud.google.com/iam/docs/service-account-overview) in it + +## Grant the service account permissions for GCP Secret Manager + +Navigate to **IAM & Admin** page in GCP and add the **Secret Manager Admin** and **Service Usage Admin** roles to the service account. + +![integrations GCP secret manager IAM](../../images/integrations/gcp-secret-manager/integrations-gcp-secret-manager-iam.png) + + + For enhanced security, you may want to assign more granular permissions to the service account. At minimum, + the service account should be able to read/write secrets from/to GCP Secret Manager (e.g. **Secret Manager Admin** role) + and list which GCP services are enabled/disabled (e.g. **Service Usage Admin** role). + + +## Navigate to your project's integrations tab + +![integrations](../../images/integrations.png) + +## Authorize Infisical for GCP + +Press on the GCP Secret Manager tile and paste in your **GCP Service Account JSON** (you can create and download the JSON for your +service account in IAM & Admin > Service Accounts > Service Account > Keys). + +![integrations GCP authorization IAM key](../../images/integrations/gcp-secret-manager/integrations-gcp-secret-manager-iam-key.png) + +![integrations GCP authorization options](../../images/integrations/gcp-secret-manager/integrations-gcp-secret-manager-auth-options.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 the GCP secret manager project. Lastly, press create integration to start syncing secrets to GCP secret manager. + +![integrations GCP secret manager](../../images/integrations/gcp-secret-manager/integrations-gcp-secret-manager-create.png) +![integrations GCP secret manager](../../images/integrations/gcp-secret-manager/integrations-gcp-secret-manager.png) + + + Using Infisical to sync secrets to GCP Secret Manager requires that you enable + the Service Usage API in the Google Cloud project you want to sync secrets to. More on that [here](https://cloud.google.com/service-usage/docs/set-up-development-environment). + + + - Using the GCP Secret Manager integration on a self-hosted instance of Infisical requires configuring an OAuth2 application in GCP + Using the GCP Secret Manager integration (via the OAuth2 method) on a self-hosted instance of Infisical requires configuring an OAuth2 application in GCP and registering your instance with it. ## Create an OAuth2 application in GCP diff --git a/frontend/src/hooks/api/integrationAuth/queries.tsx b/frontend/src/hooks/api/integrationAuth/queries.tsx index ec0c785f26..aa18738533 100644 --- a/frontend/src/hooks/api/integrationAuth/queries.tsx +++ b/frontend/src/hooks/api/integrationAuth/queries.tsx @@ -394,6 +394,7 @@ export const useSaveIntegrationAccessToken = () => { mutationFn: async ({ workspaceId, integration, + refreshToken, accessId, accessToken, url, @@ -401,14 +402,16 @@ export const useSaveIntegrationAccessToken = () => { }: { workspaceId: string | null; integration: string | undefined; - accessId: string | null; - accessToken: string; + refreshToken?: string; + accessId?: string; + accessToken?: string; url: string | null; namespace: string | null; }) => { const { data: { integrationAuth } } = await apiRequest.post("/api/v1/integration-auth/access-token", { workspaceId, integration, + refreshToken, accessId, accessToken, url, diff --git a/frontend/src/hooks/api/integrations/queries.tsx b/frontend/src/hooks/api/integrations/queries.tsx index 6e7b65eed4..f869046a10 100644 --- a/frontend/src/hooks/api/integrations/queries.tsx +++ b/frontend/src/hooks/api/integrations/queries.tsx @@ -46,8 +46,8 @@ export const useCreateIntegration = () => { integrationAuthId: string; isActive: boolean; secretPath: string; - app: string | null; - appId: string | null; + app?: string | null; + appId?: string | null; sourceEnvironment: string; targetEnvironment: string | null; targetEnvironmentId: string | null; diff --git a/frontend/src/pages/integrations/gcp-secret-manager/authorize.tsx b/frontend/src/pages/integrations/gcp-secret-manager/authorize.tsx index f63b5a7dda..99c092bcc7 100644 --- a/frontend/src/pages/integrations/gcp-secret-manager/authorize.tsx +++ b/frontend/src/pages/integrations/gcp-secret-manager/authorize.tsx @@ -1,51 +1,64 @@ +import crypto from "crypto"; import { useState } from "react"; import { useRouter } from "next/router"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { faGoogle } from "@fortawesome/free-brands-svg-icons"; import { - useSaveIntegrationAccessToken + useSaveIntegrationAccessToken, + useGetCloudIntegrations } from "@app/hooks/api"; import { Button, Card, CardTitle, FormControl, Input, TextArea } from "../../../components/v2"; export default function GCPSecretManagerAuthorizeIntegrationPage() { const router = useRouter(); + const { data: cloudIntegrations } = useGetCloudIntegrations(); + const { mutateAsync } = useSaveIntegrationAccessToken(); const [accessToken, setAccessToken] = useState(""); const [accessTokenErrorText, setAccessTokenErrorText] = useState(""); const [isLoading, setIsLoading] = useState(false); - const handleButtonClick = async () => { + const handleIntegrateWithPAT = async () => { try { setAccessTokenErrorText(""); if (accessToken.length === 0) { - setAccessTokenErrorText("Access token cannot be blank"); + setAccessTokenErrorText("Service account JSON cannot be blank"); return; } - - console.log("setAccessTokenErrorText"); - console.log("accessToken: ", accessToken); - // setIsLoading(true); + setIsLoading(true); - // const integrationAuth = await mutateAsync({ - // workspaceId: localStorage.getItem("projectData.id"), - // integration: "flyio", - // accessId: null, - // accessToken, - // url: null, - // namespace: null - // }); + const integrationAuth = await mutateAsync({ + workspaceId: localStorage.getItem("projectData.id"), + integration: "gcp-secret-manager", + refreshToken: accessToken, + url: null, + namespace: null + }); - // setIsLoading(false); + setIsLoading(false); - // router.push(`/integrations/flyio/create?integrationAuthId=${integrationAuth._id}`); + router.push(`/integrations/gcp-secret-manager/pat/create?integrationAuthId=${integrationAuth._id}`); } catch (err) { console.error(err); } }; + + const handleIntegrateWithOAuth = () => { + if (!cloudIntegrations) return; + const integrationOption = cloudIntegrations.find((integration) => integration.slug === "gcp-secret-manager"); + + if (!integrationOption) return; + + const state = crypto.randomBytes(16).toString("hex"); + localStorage.setItem("latestCSRFToken", state); + + const 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}`; + window.location.assign(link); + } return (
@@ -54,10 +67,7 @@ export default function GCPSecretManagerAuthorizeIntegrationPage() {