From 46fe72401293305cc9bc1b9dd2f8961aca57dd10 Mon Sep 17 00:00:00 2001 From: Tuan Dang Date: Tue, 6 Dec 2022 00:23:16 -0500 Subject: [PATCH 01/25] Begin developing bot, event, and integration pipeline --- backend/src/actions/index.ts | 7 ++ backend/src/actions/integration.ts | 50 ++++++++ backend/src/controllers/botController.ts | 96 ++++++++++++++++ backend/src/controllers/index.ts | 2 + .../controllers/integrationAuthController.ts | 78 +++++++------ .../src/controllers/integrationController.ts | 1 + backend/src/controllers/keyController.ts | 10 -- backend/src/controllers/secretController.ts | 16 ++- backend/src/events/index.ts | 5 + backend/src/events/secret.ts | 44 +++++++ backend/src/helpers/bot.ts | 49 ++++++++ backend/src/helpers/integrationAuth.ts | 78 ++++++++++++- backend/src/helpers/membership.ts | 52 ++++++++- backend/src/helpers/signup.ts | 2 +- backend/src/helpers/workspace.ts | 12 ++ backend/src/index.ts | 2 + backend/src/integrations/exchange.ts | 96 ++++++++++++++++ backend/src/integrations/index.ts | 7 ++ backend/src/integrations/refresh.ts | 78 +++++++++++++ backend/src/middleware/index.ts | 2 + backend/src/middleware/requireBotAuth.ts | 45 ++++++++ .../src/middleware/requireIntegrationAuth.ts | 23 ++-- .../requireIntegrationAuthorizationAuth.ts | 32 ++---- .../src/middleware/requireWorkspaceAuth.ts | 26 ++--- backend/src/models/bot.ts | 57 +++++++++ backend/src/models/botKey.ts | 45 ++++++++ backend/src/models/botSequence.ts | 38 ++++++ backend/src/models/index.ts | 9 ++ backend/src/routes/bot.ts | 39 +++++++ backend/src/routes/index.ts | 2 + backend/src/routes/integration.ts | 2 +- backend/src/routes/integrationAuth.ts | 8 +- backend/src/services/ActionService.ts | 54 +++++++++ backend/src/services/BotService.ts | 108 ++++++++++++++++++ backend/src/services/EventService.ts | 75 ++++++++++++ backend/src/services/IntegrationService.ts | 93 +++++++++++++++ backend/src/services/index.ts | 10 +- backend/src/types/express/index.d.ts | 1 + backend/src/utils/crypto.ts | 16 +++ backend/src/variables.ts | 60 ---------- backend/src/variables/action.ts | 5 + backend/src/variables/environment.ts | 14 +++ backend/src/variables/event.ts | 7 ++ backend/src/variables/index.ts | 65 +++++++++++ backend/src/variables/integration.ts | 18 +++ backend/src/variables/organization.ts | 24 ++++ backend/src/variables/secret.ts | 8 ++ backend/src/variables/stripe.ts | 7 ++ 48 files changed, 1402 insertions(+), 176 deletions(-) create mode 100644 backend/src/actions/index.ts create mode 100644 backend/src/actions/integration.ts create mode 100644 backend/src/controllers/botController.ts create mode 100644 backend/src/events/index.ts create mode 100644 backend/src/events/secret.ts create mode 100644 backend/src/helpers/bot.ts create mode 100644 backend/src/integrations/exchange.ts create mode 100644 backend/src/integrations/index.ts create mode 100644 backend/src/integrations/refresh.ts create mode 100644 backend/src/middleware/requireBotAuth.ts create mode 100644 backend/src/models/bot.ts create mode 100644 backend/src/models/botKey.ts create mode 100644 backend/src/models/botSequence.ts create mode 100644 backend/src/routes/bot.ts create mode 100644 backend/src/services/ActionService.ts create mode 100644 backend/src/services/BotService.ts create mode 100644 backend/src/services/EventService.ts create mode 100644 backend/src/services/IntegrationService.ts delete mode 100644 backend/src/variables.ts create mode 100644 backend/src/variables/action.ts create mode 100644 backend/src/variables/environment.ts create mode 100644 backend/src/variables/event.ts create mode 100644 backend/src/variables/index.ts create mode 100644 backend/src/variables/integration.ts create mode 100644 backend/src/variables/organization.ts create mode 100644 backend/src/variables/secret.ts create mode 100644 backend/src/variables/stripe.ts diff --git a/backend/src/actions/index.ts b/backend/src/actions/index.ts new file mode 100644 index 000000000..25f85cc38 --- /dev/null +++ b/backend/src/actions/index.ts @@ -0,0 +1,7 @@ +import { + actionPushToHeroku +} from './integration'; + +export { + actionPushToHeroku +} \ No newline at end of file diff --git a/backend/src/actions/integration.ts b/backend/src/actions/integration.ts new file mode 100644 index 000000000..d9bff4fd1 --- /dev/null +++ b/backend/src/actions/integration.ts @@ -0,0 +1,50 @@ +import { + Key, + Bot, + IBot, + Integration, + IntegrationAuth +} from '../models'; +import * as Sentry from '@sentry/node'; +import { BotService } from '../services'; + +interface Event { + name: string; + workspaceId: string; + payload: any; +} + +/** + * Push secrets to Heroku + * @param {Object} obj + * @param {Event} obj.event + * @param {IBot} obj.bot + */ +const actionPushToHeroku = ({ + event, + bot +}: { + event: Event, + bot: IBot +}) => { + + // TODO: push secrets in [event] + // event: name, workspaceId, payload (environment, secrets) + try { + + // 1. Bot needs to decrypt their project key + // 2. Bot needs to decrypt secrets + // 3. Query IntegrationAuth for credentials + // 4. Decrypt integration refresh and token + // 5. Query Integration for integration details + // 6. Push to integration + + } catch (err) { + Sentry.setUser(null); + Sentry.captureException(err); + } +} + +export { + actionPushToHeroku +} \ No newline at end of file diff --git a/backend/src/controllers/botController.ts b/backend/src/controllers/botController.ts new file mode 100644 index 000000000..d4bc90cf3 --- /dev/null +++ b/backend/src/controllers/botController.ts @@ -0,0 +1,96 @@ +import { Request, Response } from 'express'; +import * as Sentry from '@sentry/node'; +import { Bot, BotKey } from '../models'; +import { createBot } from '../helpers/bot'; + +interface BotKey { + encryptedKey: string; + nonce: string; +} + +/** + * Return bot for workspace with id [workspaceId]. If a workspace bot doesn't exist, + * then create and return a new bot. + * @param req + * @param res + * @returns + */ +export const getBotByWorkspaceId = async (req: Request, res: Response) => { + let bot; + try { + const { workspaceId } = req.body; + + bot = await Bot.findOne({ + workspace: workspaceId + }); + + if (!bot) { + // case: bot doesn't exist for workspace with id [workspaceId] + // -> create a new bot and return it + bot = await createBot({ + name: 'Infisical Bot', + workspaceId + }); + } + } catch (err) { + Sentry.setUser({ email: req.user.email }); + Sentry.captureException(err); + return res.status(400).send({ + message: 'Failed to get bot for workspace' + }); + } + + return res.status(200).send({ + bot + }); +}; + +/** + * Return bot with id [req.bot._id] with active state set to [isActive]. + * @param req + * @param res + * @returns + */ +export const setBotActiveState = async (req: Request, res: Response) => { + let bot; + try { + const { isActive, botKey }: { isActive: boolean, botKey: BotKey } = req.body; + + if (isActive) { + // bot state set to active -> share workspace key with bot + await new BotKey({ + encryptedKey: botKey.encryptedKey, + nonce: botKey.nonce, + sender: req.user._id, + receiver: req.bot._id, + workspace: req.bot.workspace + }).save(); + } else { + // case: bot state set to inactive -> delete bot's workspace key + await BotKey.deleteOne({ + bot: req.bot._id + }); + } + + bot = await Bot.findOneAndUpdate({ + _id: req.bot._id + }, { + isActive + }, { + new: true + }); + + if (!bot) throw new Error('Failed to update bot active state'); + + } catch (err) { + Sentry.setUser({ email: req.user.email }); + Sentry.captureException(err); + return res.status(400).send({ + message: 'Failed to update bot active state' + }); + } + + return res.status(200).send({ + bot + }); +}; diff --git a/backend/src/controllers/index.ts b/backend/src/controllers/index.ts index 2d3debfb5..1da61835f 100644 --- a/backend/src/controllers/index.ts +++ b/backend/src/controllers/index.ts @@ -1,4 +1,5 @@ import * as authController from './authController'; +import * as botController from './botController'; import * as integrationAuthController from './integrationAuthController'; import * as integrationController from './integrationController'; import * as keyController from './keyController'; @@ -16,6 +17,7 @@ import * as workspaceController from './workspaceController'; export { authController, + botController, integrationAuthController, integrationController, keyController, diff --git a/backend/src/controllers/integrationAuthController.ts b/backend/src/controllers/integrationAuthController.ts index 009bcd391..fcdb40495 100644 --- a/backend/src/controllers/integrationAuthController.ts +++ b/backend/src/controllers/integrationAuthController.ts @@ -7,6 +7,8 @@ import { processOAuthTokenRes } from '../helpers/integrationAuth'; import { INTEGRATION_SET, ENV_DEV } from '../variables'; import { OAUTH_CLIENT_SECRET_HEROKU, OAUTH_TOKEN_URL_HEROKU } from '../config'; +import { IntegrationService } from '../services'; + /** * Perform OAuth2 code-token exchange as part of integration [integration] for workspace with id [workspaceId] * Note: integration [integration] must be set up compatible/designed for OAuth2 @@ -14,58 +16,64 @@ import { OAUTH_CLIENT_SECRET_HEROKU, OAUTH_TOKEN_URL_HEROKU } from '../config'; * @param res * @returns */ -export const integrationAuthOauthExchange = async ( +export const oAuthExchange = async ( req: Request, res: Response ) => { try { - let clientSecret; + // let clientSecret; const { workspaceId, code, integration } = req.body; if (!INTEGRATION_SET.has(integration)) throw new Error('Failed to validate integration'); - - // use correct client secret - switch (integration) { - case 'heroku': - clientSecret = OAUTH_CLIENT_SECRET_HEROKU; - } - - // TODO: unfinished - make compatible with other integration types - const res = await axios.post( - OAUTH_TOKEN_URL_HEROKU!, - new URLSearchParams({ - grant_type: 'authorization_code', - code: code, - client_secret: clientSecret - } as any) - ); - - const integrationAuth = await processOAuthTokenRes({ + + await IntegrationService.handleOAuthExchange({ workspaceId, integration, - res + code }); - // create or replace integration - const integrationObj = await Integration.findOneAndUpdate( - { workspace: workspaceId, integration }, - { - workspace: workspaceId, - environment: ENV_DEV, - isActive: false, - app: null, - integration, - integrationAuth: integrationAuth._id - }, - { upsert: true, new: true } - ); + // // use correct client secret + // switch (integration) { + // case 'heroku': + // clientSecret = OAUTH_CLIENT_SECRET_HEROKU; + // } + + // // TODO: unfinished - make compatible with other integration types + // const res = await axios.post( // this response may be different for each integration + // OAUTH_TOKEN_URL_HEROKU!, + // new URLSearchParams({ + // grant_type: 'authorization_code', + // code: code, + // client_secret: clientSecret + // } as any) + // ); + + // const integrationAuth = await processOAuthTokenRes({ + // workspaceId, + // integration, + // res + // }); + + // // create or replace integration + // const integrationObj = await Integration.findOneAndUpdate( + // { workspace: workspaceId, integration }, + // { + // workspace: workspaceId, + // environment: ENV_DEV, + // isActive: false, + // app: null, + // integration, + // integrationAuth: integrationAuth._id + // }, + // { upsert: true, new: true } + // ); } catch (err) { Sentry.setUser(null); Sentry.captureException(err); return res.status(400).send({ - message: 'Failed to get OAuth2 token' + message: 'Failed to get OAuth2 code-token exchange' }); } diff --git a/backend/src/controllers/integrationController.ts b/backend/src/controllers/integrationController.ts index b75d9b74a..3c6ebba56 100644 --- a/backend/src/controllers/integrationController.ts +++ b/backend/src/controllers/integrationController.ts @@ -49,6 +49,7 @@ export const getIntegrations = async (req: Request, res: Response) => { }); }; +// TODO: deprecate /** * Sync secrets [secrets] to integration with id [integrationId] * @param req diff --git a/backend/src/controllers/keyController.ts b/backend/src/controllers/keyController.ts index 1c9b5e15c..778d44b60 100644 --- a/backend/src/controllers/keyController.ts +++ b/backend/src/controllers/keyController.ts @@ -17,16 +17,6 @@ export const uploadKey = async (req: Request, res: Response) => { const { workspaceId } = req.params; const { key } = req.body; - // validate membership of sender - const senderMembership = await findMembership({ - user: req.user._id, - workspace: workspaceId - }); - - if (!senderMembership) { - throw new Error('Failed sender membership validation for workspace'); - } - // validate membership of receiver const receiverMembership = await findMembership({ user: key.userId, diff --git a/backend/src/controllers/secretController.ts b/backend/src/controllers/secretController.ts index d1cf5f65d..cd91d3dbe 100644 --- a/backend/src/controllers/secretController.ts +++ b/backend/src/controllers/secretController.ts @@ -7,8 +7,9 @@ import { reformatPullSecrets } from '../helpers/secret'; import { pushKeys } from '../helpers/key'; +import { eventPushSecrets } from '../events'; +import { EventService } from '../services'; import { ENV_SET } from '../variables'; - import { postHogClient } from '../services'; interface PushSecret { @@ -60,7 +61,16 @@ export const pushSecrets = async (req: Request, res: Response) => { workspaceId, keys }); - + + // trigger event + EventService.handleEvent({ + event: eventPushSecrets({ + workspaceId, + environment, + secrets + }) + }); + if (postHogClient) { postHogClient.capture({ event: 'secrets pushed', @@ -192,7 +202,7 @@ export const pullSecretsServiceToken = async (req: Request, res: Response) => { }; if (postHogClient) { - // capture secrets pushed event in production + // capture secrets pulled event in production postHogClient.capture({ distinctId: req.serviceToken.user.email, event: 'secrets pulled', diff --git a/backend/src/events/index.ts b/backend/src/events/index.ts new file mode 100644 index 000000000..461a3ece6 --- /dev/null +++ b/backend/src/events/index.ts @@ -0,0 +1,5 @@ +import { eventPushSecrets } from "./secret" + +export { + eventPushSecrets +} \ No newline at end of file diff --git a/backend/src/events/secret.ts b/backend/src/events/secret.ts new file mode 100644 index 000000000..771602d0e --- /dev/null +++ b/backend/src/events/secret.ts @@ -0,0 +1,44 @@ +import { EVENT_PUSH_SECRETS } from '../variables'; + +interface PushSecret { + ciphertextKey: string; + ivKey: string; + tagKey: string; + hashKey: string; + ciphertextValue: string; + ivValue: string; + tagValue: string; + hashValue: string; + type: 'shared' | 'personal'; +} + +/** + * Return event for pushing secrets + * @param {Object} obj + * @param {String} obj.workspaceId - id of workspace to push secrets to + * @param {String} obj.environment - environment for secrets + * @param {PushSecret[]} obj.secrets - secrets to push + * @returns + */ +const eventPushSecrets = ({ + workspaceId, + environment, + secrets +}: { + workspaceId: string; + environment: string; + secrets: PushSecret[]; +}) => { + return ({ + name: EVENT_PUSH_SECRETS, + workspaceId, + payload: { + environment, + secrets + } + }); +} + +export { + eventPushSecrets +} diff --git a/backend/src/helpers/bot.ts b/backend/src/helpers/bot.ts new file mode 100644 index 000000000..e66fe4489 --- /dev/null +++ b/backend/src/helpers/bot.ts @@ -0,0 +1,49 @@ +import * as Sentry from '@sentry/node'; +import { + Bot +} from '../models'; +import { generateKeyPair, encryptSymmetric } from '../utils/crypto'; +import { ENCRYPTION_KEY } from '../config'; + +/** + * Create an inactive bot with name [name] for workspace with id [workspaceId] + * @param {Object} obj + * @param {String} obj.name - name of bot + * @param {String} obj.workspaceId - id of workspace that bot belongs to + */ +const createBot = async ({ + name, + workspaceId, +}: { + name: string; + workspaceId: string; +}) => { + let bot; + try { + const { publicKey, privateKey } = generateKeyPair(); + const { ciphertext, iv, tag } = encryptSymmetric({ + plaintext: privateKey, + key: ENCRYPTION_KEY + }); + + bot = await new Bot({ + name, + workspace: workspaceId, + isActive: false, + publicKey, + encryptedPrivateKey: ciphertext, + iv, + tag + }).save(); + } catch (err) { + Sentry.setUser(null); + Sentry.captureException(err); + throw new Error('Failed to create bot'); + } + + return bot; +} + +export { + createBot +} \ No newline at end of file diff --git a/backend/src/helpers/integrationAuth.ts b/backend/src/helpers/integrationAuth.ts index 17f101676..dddad5c8a 100644 --- a/backend/src/helpers/integrationAuth.ts +++ b/backend/src/helpers/integrationAuth.ts @@ -9,6 +9,81 @@ import { OAUTH_TOKEN_URL_HEROKU } from '../config'; +/** + * Encrypt access and refresh tokens, compute new access token expiration times [accessExpiresAt], + * and upsert them into the DB for workspace with id [workspaceId] and integration [integration]. + * @param {Object} obj + * @param {String} obj.workspaceId - id of workspace + * @param {String} obj.integration - name of integration + * @param {String} obj.accessToken - access token for integration + * @param {Date} obj.accessExpiresAt - date of expiration for access token + * @param {String} obj.refreshToken - refresh token for integration +*/ +const processOAuthTokenRes2 = async ({ + workspaceId, + integration, + accessToken, + accessExpiresAt, + refreshToken, +}: { + workspaceId: string; + integration: string; + accessToken: string; + accessExpiresAt: Date; + refreshToken: string; +}) => { + + let integrationAuth; + try { + // encrypt refresh + access tokens + const { + ciphertext: refreshCiphertext, + iv: refreshIV, + tag: refreshTag + } = encryptSymmetric({ + plaintext: refreshToken, + key: ENCRYPTION_KEY + }); + + const { + ciphertext: accessCiphertext, + iv: accessIV, + tag: accessTag + } = encryptSymmetric({ + plaintext: accessToken, + key: ENCRYPTION_KEY + }); + + // create or replace integration authorization with encrypted tokens + // and access token expiration date + integrationAuth = await IntegrationAuth.findOneAndUpdate( + { workspace: workspaceId, integration }, + { + workspace: workspaceId, + integration, + refreshCiphertext, + refreshIV, + refreshTag, + accessCiphertext, + accessIV, + accessTag, + accessExpiresAt + }, + { upsert: true, new: true } + ); + + } catch (err) { + Sentry.setUser(null); + Sentry.captureException(err); + throw new Error( + 'Failed to process OAuth2 authorization server token response' + ); + } + + return integrationAuth; +} + +// TODO: deprecate /** * Process token exchange and refresh responses from respective OAuth2 authorization servers by * encrypting access and refresh tokens, computing new access token expiration times [accessExpiresAt], @@ -82,6 +157,7 @@ const processOAuthTokenRes = async ({ return integrationAuth; }; +// TODO: deprecate /** * Return access token for integration either by decrypting a non-expired access token [accessCiphertext] on * the integration authorization document or by requesting a new one by decrypting and exchanging the @@ -171,4 +247,4 @@ const getOAuthAccessToken = async ({ return accessToken; }; -export { processOAuthTokenRes, getOAuthAccessToken }; +export { processOAuthTokenRes, processOAuthTokenRes2, getOAuthAccessToken }; diff --git a/backend/src/helpers/membership.ts b/backend/src/helpers/membership.ts index 14cd567bb..1ff542f2a 100644 --- a/backend/src/helpers/membership.ts +++ b/backend/src/helpers/membership.ts @@ -1,6 +1,51 @@ import * as Sentry from '@sentry/node'; import { Membership, Key } from '../models'; +/** + * Validate that user with id [userId] is a member of workspace with id [workspaceId] + * and has at least one of the roles in [acceptedRoles] and statuses in [acceptedStatuses] + * @param {Object} obj + * @param {String} obj.userId - id of user to validate + * @param {String} obj.workspaceId - id of workspace + */ +const validateMembership = async ({ + userId, + workspaceId, + acceptedRoles, + acceptedStatuses +}: { + userId: string; + workspaceId: string; + acceptedRoles: string[]; + acceptedStatuses: string[]; +}) => { + + let membership; + try { + membership = await Membership.findOne({ + user: userId, + workspace: workspaceId + }); + + if (!membership) throw new Error('Failed to find membership'); + + if (!acceptedRoles.includes(membership.role)) { + throw new Error('Failed to validate membership role'); + } + + if (!acceptedStatuses.includes(membership.status)) { + throw new Error('Failed to validate membership status'); + } + + } catch (err) { + Sentry.setUser(null); + Sentry.captureException(err); + throw new Error('Failed to validate membership'); + } + + return membership; +} + /** * Return membership matching criteria specified in query [queryObj] * @param {Object} queryObj - query object @@ -97,4 +142,9 @@ const deleteMembership = async ({ membershipId }: { membershipId: string }) => { return deletedMembership; }; -export { addMemberships, findMembership, deleteMembership }; +export { + validateMembership, + addMemberships, + findMembership, + deleteMembership +}; diff --git a/backend/src/helpers/signup.ts b/backend/src/helpers/signup.ts index 410c442b3..3229910a5 100644 --- a/backend/src/helpers/signup.ts +++ b/backend/src/helpers/signup.ts @@ -33,7 +33,7 @@ const sendEmailVerification = async ({ email }: { email: string }) => { // send mail await sendMail({ template: 'emailVerification.handlebars', - subjectLine: 'Infisical workspace invitation', + subjectLine: 'Infisical confirmation code', recipients: [email], substitutions: { code: token diff --git a/backend/src/helpers/workspace.ts b/backend/src/helpers/workspace.ts index 52d7d227b..34d5a53e7 100644 --- a/backend/src/helpers/workspace.ts +++ b/backend/src/helpers/workspace.ts @@ -1,13 +1,16 @@ import * as Sentry from '@sentry/node'; import { Workspace, + Bot, Membership, Key, Secret } from '../models'; +import { createBot } from '../helpers/bot'; /** * Create a workspace with name [name] in organization with id [organizationId] + * and a bot for it. * @param {String} name - name of workspace to create. * @param {String} organizationId - id of organization to create workspace in * @param {Object} workspace - new workspace @@ -21,10 +24,16 @@ const createWorkspace = async ({ }) => { let workspace; try { + // create workspace workspace = await new Workspace({ name, organization: organizationId }).save(); + + // const bot = await createBot({ + // name: 'Infisical Bot', + // workspaceId: workspace._id.toString() + // }); } catch (err) { Sentry.setUser(null); Sentry.captureException(err); @@ -43,6 +52,9 @@ const createWorkspace = async ({ const deleteWorkspace = async ({ id }: { id: string }) => { try { await Workspace.deleteOne({ _id: id }); + await Bot.deleteOne({ + workspace: id + }); await Membership.deleteMany({ workspace: id }); diff --git a/backend/src/index.ts b/backend/src/index.ts index a5ae44969..61872184c 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -22,6 +22,7 @@ Sentry.init({ import { signup as signupRouter, auth as authRouter, + bot as botRouter, organization as organizationRouter, workspace as workspaceRouter, membershipOrg as membershipOrgRouter, @@ -71,6 +72,7 @@ app.use(express.json()); // routers app.use('/api/v1/signup', signupRouter); app.use('/api/v1/auth', authRouter); +app.use('/api/v1/bot', botRouter); app.use('/api/v1/user', userRouter); app.use('/api/v1/user-action', userActionRouter); app.use('/api/v1/organization', organizationRouter); diff --git a/backend/src/integrations/exchange.ts b/backend/src/integrations/exchange.ts new file mode 100644 index 000000000..96ca8d26d --- /dev/null +++ b/backend/src/integrations/exchange.ts @@ -0,0 +1,96 @@ +import axios from 'axios'; +import * as Sentry from '@sentry/node'; +import { + INTEGRATION_HEROKU, + ACTION_PUSH_TO_HEROKU +} from '../variables'; +import { + OAUTH_CLIENT_SECRET_HEROKU, + OAUTH_TOKEN_URL_HEROKU +} from '../config'; + +/** + * Return [accessToken], [accessExpiresAt], and [refreshToken] for OAuth2 + * code-token exchange for integration named [integration] + * @param {Object} obj1 + * @param {String} obj1.integration - name of integration + * @param {String} obj1.code - code for code-token exchange + * @returns {Object} obj + * @returns {String} obj.accessToken - access token for integration + * @returns {String} obj.refreshToken - refresh token for integration + * @returns {Date} obj.accessExpiresAt - date of expiration for access token + * @returns {String} obj.action - integration action for bot sequence + */ +const exchangeCode = async ({ + integration, + code +}: { + integration: string; + code: string; +}) => { + let obj = {} as any; + try { + switch (integration) { + case INTEGRATION_HEROKU: + obj = await exchangeCodeHeroku({ + code + }); + obj['action'] = ACTION_PUSH_TO_HEROKU; + break; + } + } catch (err) { + Sentry.setUser(null); + Sentry.captureException(err); + throw new Error('Failed OAuth2 code-token exchange'); + } + + return obj; +} + +/** + * Return [accessToken], [accessExpiresAt], and [refreshToken] for Heroku + * OAuth2 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 Heroku API + * @returns {String} obj2.refreshToken - refresh token for Heroku API + * @returns {Date} obj2.accessExpiresAt - date of expiration for access token + */ +const exchangeCodeHeroku = async ({ + code +}: { + code: string; +}) => { + let res: any; + let accessExpiresAt: any; + try { + res = await axios.post( + OAUTH_TOKEN_URL_HEROKU!, + new URLSearchParams({ + grant_type: 'authorization_code', + code: code, + client_secret: OAUTH_CLIENT_SECRET_HEROKU + } as any) + ); + + accessExpiresAt.setSeconds( + accessExpiresAt.getSeconds() + res.data.expires_in + ); + } catch (err) { + console.error('integrationHerokuExchange'); + Sentry.setUser(null); + Sentry.captureException(err); + throw new Error('Failed OAuth2 code-token exchange with Heroku'); + } + + return ({ + accessToken: res.data.access_token, + refreshToken: res.data.refresh_token, + accessExpiresAt + }); +} + +export { + exchangeCode +} \ No newline at end of file diff --git a/backend/src/integrations/index.ts b/backend/src/integrations/index.ts new file mode 100644 index 000000000..a97711cb5 --- /dev/null +++ b/backend/src/integrations/index.ts @@ -0,0 +1,7 @@ +import { exchangeCode } from './exchange'; +import { exchangeRefresh } from './refresh'; + +export { + exchangeCode, + exchangeRefresh +} \ No newline at end of file diff --git a/backend/src/integrations/refresh.ts b/backend/src/integrations/refresh.ts new file mode 100644 index 000000000..730c5a726 --- /dev/null +++ b/backend/src/integrations/refresh.ts @@ -0,0 +1,78 @@ +import axios from 'axios'; +import * as Sentry from '@sentry/node'; +import { INTEGRATION_HEROKU } from '../variables'; +import { + OAUTH_CLIENT_SECRET_HEROKU +} from '../config'; +import { + OAUTH_TOKEN_URL_HEROKU +} from '../variables'; + +/** + * Return new access token by exchanging refresh token [refreshToken] for integration + * named [integration] + * @param {Object} obj + * @param {String} obj.integration - name of integration + * @param {String} obj.refreshToken - refresh token to use to get new access token for Heroku + */ +const exchangeRefresh = async ({ + integration, + refreshToken +}: { + integration: string; + refreshToken: string; +}) => { + let accessToken; + try { + switch (integration) { + case INTEGRATION_HEROKU: + accessToken = await exchangeRefreshHeroku({ + refreshToken + }); + break; + } + } catch (err) { + Sentry.setUser(null); + Sentry.captureException(err); + throw new Error('Failed to get new OAuth2 access token'); + } + + return accessToken; +} + +/** + * Return new access token by exchanging refresh token [refreshToken] for the + * Heroku integration + * @param {Object} obj + * @param {String} obj.refreshToken - refresh token to use to get new access token for Heroku + * @returns + */ +const exchangeRefreshHeroku = async ({ + refreshToken +}: { + refreshToken: string; +}) => { + let accessToken; + try { + const res = await axios.post( + OAUTH_TOKEN_URL_HEROKU, + new URLSearchParams({ + grant_type: 'refresh_token', + refresh_token: refreshToken, + client_secret: OAUTH_CLIENT_SECRET_HEROKU + } as any) + ); + + accessToken = res.data.access_token; + } catch (err) { + Sentry.setUser(null); + Sentry.captureException(err); + throw new Error('Failed to get new OAuth2 access token for Heroku'); + } + + return accessToken; +} + +export { + exchangeRefresh +} \ No newline at end of file diff --git a/backend/src/middleware/index.ts b/backend/src/middleware/index.ts index e445b64cf..7fcba66e1 100644 --- a/backend/src/middleware/index.ts +++ b/backend/src/middleware/index.ts @@ -1,4 +1,5 @@ import requireAuth from './requireAuth'; +import requireBotAuth from './requireBotAuth'; import requireSignupAuth from './requireSignupAuth'; import requireWorkspaceAuth from './requireWorkspaceAuth'; import requireOrganizationAuth from './requireOrganizationAuth'; @@ -9,6 +10,7 @@ import validateRequest from './validateRequest'; export { requireAuth, + requireBotAuth, requireSignupAuth, requireWorkspaceAuth, requireOrganizationAuth, diff --git a/backend/src/middleware/requireBotAuth.ts b/backend/src/middleware/requireBotAuth.ts new file mode 100644 index 000000000..6c5a3820a --- /dev/null +++ b/backend/src/middleware/requireBotAuth.ts @@ -0,0 +1,45 @@ +import * as Sentry from '@sentry/node'; +import { Request, Response, NextFunction } from 'express'; +import { Bot } from '../models'; +import { validateMembership } from '../helpers/membership'; + +type req = 'params' | 'body' | 'query'; + +const requireBotAuth = ({ + acceptedRoles, + acceptedStatuses, + location = 'params' +}: { + acceptedRoles: string[]; + acceptedStatuses: string[]; + location?: req; +}) => { + return async (req: Request, res: Response, next: NextFunction) => { + try { + const bot = await Bot.findOne({ _id: req[location].botId }); + + if (!bot) { + throw new Error('Failed to find bot'); + } + + await validateMembership({ + userId: req.user._id.toString(), + workspaceId: bot.workspace.toString(), + acceptedRoles, + acceptedStatuses + }); + + req.bot = bot; + + next(); + } catch (err) { + Sentry.setUser(null); + Sentry.captureException(err); + return res.status(401).send({ + error: 'Failed bot authorization' + }); + } + } +} + +export default requireBotAuth; \ No newline at end of file diff --git a/backend/src/middleware/requireIntegrationAuth.ts b/backend/src/middleware/requireIntegrationAuth.ts index 70ca320c3..dacc0f863 100644 --- a/backend/src/middleware/requireIntegrationAuth.ts +++ b/backend/src/middleware/requireIntegrationAuth.ts @@ -2,6 +2,7 @@ import * as Sentry from '@sentry/node'; import { Request, Response, NextFunction } from 'express'; import { Integration, IntegrationAuth, Membership } from '../models'; import { getOAuthAccessToken } from '../helpers/integrationAuth'; +import { validateMembership } from '../helpers/membership'; /** * Validate if user on request is a member of workspace with proper roles associated @@ -31,24 +32,14 @@ const requireIntegrationAuth = ({ if (!integration) { throw new Error('Failed to find integration'); } - - const membership = await Membership.findOne({ - user: req.user._id, - workspace: integration.workspace + + await validateMembership({ + userId: req.user._id.toString(), + workspaceId: integration.workspace.toString(), + acceptedRoles, + acceptedStatuses }); - if (!membership) { - throw new Error('Failed to find integration workspace membership'); - } - - if (!acceptedRoles.includes(membership.role)) { - throw new Error('Failed to validate workspace membership role'); - } - - if (!acceptedStatuses.includes(membership.status)) { - throw new Error('Failed to validate workspace membership status'); - } - const integrationAuth = await IntegrationAuth.findOne({ _id: integration.integrationAuth }).select( diff --git a/backend/src/middleware/requireIntegrationAuthorizationAuth.ts b/backend/src/middleware/requireIntegrationAuthorizationAuth.ts index 1f5c6dfc8..8fc5d329e 100644 --- a/backend/src/middleware/requireIntegrationAuthorizationAuth.ts +++ b/backend/src/middleware/requireIntegrationAuthorizationAuth.ts @@ -1,8 +1,8 @@ import * as Sentry from '@sentry/node'; import { Request, Response, NextFunction } from 'express'; -import { IntegrationAuth, Membership } from '../models'; -import { decryptSymmetric } from '../utils/crypto'; +import { IntegrationAuth } from '../models'; import { getOAuthAccessToken } from '../helpers/integrationAuth'; +import { validateMembership } from '../helpers/membership'; /** * Validate if user on request is a member of workspace with proper roles associated @@ -20,8 +20,6 @@ const requireIntegrationAuthorizationAuth = ({ acceptedStatuses: string[]; }) => { return async (req: Request, res: Response, next: NextFunction) => { - // (authorization) integration authorization middleware - try { const { integrationAuthId } = req.params; @@ -34,29 +32,15 @@ const requireIntegrationAuthorizationAuth = ({ if (!integrationAuth) { throw new Error('Failed to find integration authorization'); } - - const membership = await Membership.findOne({ - user: req.user._id, - workspace: integrationAuth.workspace + + await validateMembership({ + userId: req.user._id.toString(), + workspaceId: integrationAuth.workspace.toString(), + acceptedRoles, + acceptedStatuses }); - if (!membership) { - throw new Error( - 'Failed to find integration authorization workspace membership' - ); - } - - if (!acceptedRoles.includes(membership.role)) { - throw new Error('Failed to validate workspace membership role'); - } - - if (!acceptedStatuses.includes(membership.status)) { - throw new Error('Failed to validate workspace membership status'); - } - req.integrationAuth = integrationAuth; - - // TODO: make compatible with other integration types since they won't necessarily have access tokens req.accessToken = await getOAuthAccessToken({ integrationAuth }); return next(); } catch (err) { diff --git a/backend/src/middleware/requireWorkspaceAuth.ts b/backend/src/middleware/requireWorkspaceAuth.ts index 03fb7357f..a27611f2d 100644 --- a/backend/src/middleware/requireWorkspaceAuth.ts +++ b/backend/src/middleware/requireWorkspaceAuth.ts @@ -1,6 +1,6 @@ import * as Sentry from '@sentry/node'; import { Request, Response, NextFunction } from 'express'; -import { Membership, IWorkspace } from '../models'; +import { validateMembership } from '../helpers/membership'; type req = 'params' | 'body' | 'query'; @@ -25,24 +25,12 @@ const requireWorkspaceAuth = ({ // workspace authorization middleware try { - // validate workspace membership - - const membership = await Membership.findOne({ - user: req.user._id, - workspace: req[location].workspaceId - }).populate<{ workspace: IWorkspace }>('workspace'); - - if (!membership) { - throw new Error('Failed to find workspace membership'); - } - - if (!acceptedRoles.includes(membership.role)) { - throw new Error('Failed to validate workspace membership role'); - } - - if (!acceptedStatuses.includes(membership.status)) { - throw new Error('Failed to validate workspace membership status'); - } + const membership = await validateMembership({ + userId: req.user._id.toString(), + workspaceId: req[location].workspaceId, + acceptedRoles, + acceptedStatuses + }); req.membership = membership; diff --git a/backend/src/models/bot.ts b/backend/src/models/bot.ts new file mode 100644 index 000000000..f492b31ba --- /dev/null +++ b/backend/src/models/bot.ts @@ -0,0 +1,57 @@ +import { Schema, model, Types } from 'mongoose'; + +export interface IBot { + _id: Types.ObjectId; + name: string; + workspace: Types.ObjectId; + isActive: boolean; + publicKey: string; + encryptedPrivateKey: string; + iv: string; + tag: string; +} + +const botSchema = new Schema( + { + name: { + type: String, + required: true + }, + workspace: { + type: Schema.Types.ObjectId, + ref: 'Workspace', + required: true + }, + isActive: { + type: Boolean, + required: true + }, + publicKey: { + type: String, + required: true, + select: false + }, + encryptedPrivateKey: { + type: String, + required: true, + select: false + }, + iv: { + type: String, + required: true, + select: false + }, + tag: { + type: String, + required: true, + select: false + } + }, + { + timestamps: true + } +); + +const Bot = model('Bot', botSchema); + +export default Bot; diff --git a/backend/src/models/botKey.ts b/backend/src/models/botKey.ts new file mode 100644 index 000000000..79555cd53 --- /dev/null +++ b/backend/src/models/botKey.ts @@ -0,0 +1,45 @@ +import { Schema, model, Types } from 'mongoose'; + +export interface IBotKey { + _id: Types.ObjectId; + encryptedKey: string; + nonce: string; + sender: Types.ObjectId; + bot: Types.ObjectId; + workspace: Types.ObjectId; +} + +const botKeySchema = new Schema( + { + encryptedKey: { + type: String, + required: true + }, + nonce: { + type: String, + required: true + }, + sender: { + type: Schema.Types.ObjectId, + ref: 'User', + required: true + }, + bot: { + type: Schema.Types.ObjectId, + ref: 'Bot', + required: true + }, + workspace: { + type: Schema.Types.ObjectId, + ref: 'Workspace', + required: true + } + }, + { + timestamps: true + } +); + +const BotKey = model('BotKey', botKeySchema); + +export default BotKey; diff --git a/backend/src/models/botSequence.ts b/backend/src/models/botSequence.ts new file mode 100644 index 000000000..611214284 --- /dev/null +++ b/backend/src/models/botSequence.ts @@ -0,0 +1,38 @@ +import { Schema, model, Types } from 'mongoose'; + +export interface IBotSequence { + _id: Types.ObjectId; + bot: Types.ObjectId; + name: string; + event: string; + action: string; +} + +const botSequence = new Schema( + { + bot: { + type: Schema.Types.ObjectId, + ref: 'Bot', + required: true + }, + name: { + type: String, + required: true + }, + event: { + type: String, + required: true + }, + action: { + type: String, + required: true + } + }, + { + timestamps: true + } +); + +const BotSequence = model('BotSequence', botSequence); + +export default BotSequence; \ No newline at end of file diff --git a/backend/src/models/index.ts b/backend/src/models/index.ts index 9b07f6766..eef3e5fbb 100644 --- a/backend/src/models/index.ts +++ b/backend/src/models/index.ts @@ -1,4 +1,7 @@ import BackupPrivateKey, { IBackupPrivateKey } from './backupPrivateKey'; +import Bot, { IBot } from './bot'; +import BotKey, { IBotKey } from './botKey'; +import BotSequence, { IBotSequence } from './botSequence'; import IncidentContactOrg, { IIncidentContactOrg } from './incidentContactOrg'; import Integration, { IIntegration } from './integration'; import IntegrationAuth, { IIntegrationAuth } from './integrationAuth'; @@ -16,6 +19,12 @@ import Workspace, { IWorkspace } from './workspace'; export { BackupPrivateKey, IBackupPrivateKey, + Bot, + IBot, + BotKey, + IBotKey, + BotSequence, + IBotSequence, IncidentContactOrg, IIncidentContactOrg, Integration, diff --git a/backend/src/routes/bot.ts b/backend/src/routes/bot.ts new file mode 100644 index 000000000..e9f9380e9 --- /dev/null +++ b/backend/src/routes/bot.ts @@ -0,0 +1,39 @@ +import express from 'express'; +const router = express.Router(); +import { body } from 'express-validator'; +import { + requireAuth, + requireBotAuth, + requireWorkspaceAuth, + validateRequest +} from '../middleware'; +import { botController } from '../controllers'; +import { ADMIN, MEMBER, COMPLETED, GRANTED } from '../variables'; + +router.get( + '/', + requireAuth, + requireWorkspaceAuth({ + acceptedRoles: [ADMIN, MEMBER], + acceptedStatuses: [COMPLETED, GRANTED], + location: 'body' + }), + body('workspaceId').exists().trim().notEmpty(), + validateRequest, + botController.getBotByWorkspaceId +); + +router.patch( + '/:botId/active', + requireAuth, + requireBotAuth({ + acceptedRoles: [ADMIN, MEMBER], + acceptedStatuses: [COMPLETED, GRANTED] + }), + body('isActive').isBoolean(), + body('botKey'), + validateRequest, + botController.setBotActiveState +); + +export default router; \ No newline at end of file diff --git a/backend/src/routes/index.ts b/backend/src/routes/index.ts index cf015abfb..2dfe58baa 100644 --- a/backend/src/routes/index.ts +++ b/backend/src/routes/index.ts @@ -1,4 +1,5 @@ import signup from './signup'; +import bot from './bot'; import auth from './auth'; import user from './user'; import userAction from './userAction'; @@ -18,6 +19,7 @@ import integrationAuth from './integrationAuth'; export { signup, auth, + bot, user, userAction, organization, diff --git a/backend/src/routes/integration.ts b/backend/src/routes/integration.ts index d16154172..38b774ecb 100644 --- a/backend/src/routes/integration.ts +++ b/backend/src/routes/integration.ts @@ -11,7 +11,7 @@ import { integrationController } from '../controllers'; router.get('/integrations', requireAuth, integrationController.getIntegrations); -router.post( +router.post( // TODO: deprecate '/:integrationId/sync', requireAuth, requireIntegrationAuth({ diff --git a/backend/src/routes/integrationAuth.ts b/backend/src/routes/integrationAuth.ts index 61e5f56bf..dc60c7643 100644 --- a/backend/src/routes/integrationAuth.ts +++ b/backend/src/routes/integrationAuth.ts @@ -10,7 +10,7 @@ import { import { ADMIN, MEMBER, GRANTED } from '../variables'; import { integrationAuthController } from '../controllers'; -router.post( +router.post( // semi-ok '/oauth-token', requireAuth, requireWorkspaceAuth({ @@ -22,10 +22,10 @@ router.post( body('code').exists().trim().notEmpty(), body('integration').exists().trim().notEmpty(), validateRequest, - integrationAuthController.integrationAuthOauthExchange + integrationAuthController.oAuthExchange ); -router.get( +router.get( // not-ok '/:integrationAuthId/apps', requireAuth, requireIntegrationAuthorizationAuth({ @@ -37,7 +37,7 @@ router.get( integrationAuthController.getIntegrationAuthApps ); -router.delete( +router.delete( // not-ok '/:integrationAuthId', requireAuth, requireIntegrationAuthorizationAuth({ diff --git a/backend/src/services/ActionService.ts b/backend/src/services/ActionService.ts new file mode 100644 index 000000000..7d6de9dcb --- /dev/null +++ b/backend/src/services/ActionService.ts @@ -0,0 +1,54 @@ +import * as Sentry from '@sentry/node'; +import { IBot } from '../models'; +import { ACTION_PUSH_TO_HEROKU } from '../variables'; +import { actionPushToHeroku } from '../actions'; + +interface Event { + name: string; + workspaceId: string; + payload: any; +} + +/** + * Class to handle actions + */ +class ActionService { + /** + * @param {Object} obj + * @param {String} action - name of action to trigger + * @param {Event} event + * @param {String} obj.event.name - name of event + * @param {String} obj.event.workspaceId - id of workspace that event is part of + * @param {Object} obj.event.payload - payload of event (depends on event) + * @param bot + * @returns + */ + static async handleAction({ + action, + event, + bot + }: { + action: string; + event: Event; + bot: IBot; + }) { + try { + switch (action) { + case ACTION_PUSH_TO_HEROKU: + actionPushToHeroku({ + event, + bot + }); + return; + default: + return; + } + } catch (err) { + console.error('EventService err', err); + Sentry.setUser(null); + Sentry.captureException(err); + } + } +} + +export default ActionService; \ No newline at end of file diff --git a/backend/src/services/BotService.ts b/backend/src/services/BotService.ts new file mode 100644 index 000000000..a7bbd63bb --- /dev/null +++ b/backend/src/services/BotService.ts @@ -0,0 +1,108 @@ +import { + Bot, + IBot, + BotKey, + IBotKey, + IUser, + Secret +} from '../models'; +import * as Sentry from '@sentry/node'; +import { + decryptAsymmetric, + decryptSymmetric +} from '../utils/crypto'; +import { + ENCRYPTION_KEY +} from '../config'; + +/** + * Class to handle bot actions + */ +class BotService { + + /** + * Return decrypted secrets using bot + * @param {Object} obj + * @param {String} obj.workspaceId - id of workspace of secrets + * @param {String} obj.environment - environment for secrets + */ + static async decryptSecrets({ + workspaceId, + environment + }: { + workspaceId: string; + environment: string; + }) { + + let content: any = {}; + let bot; + let botKey; + try { + + // find bot + bot = await Bot.findOne({ + workspace: workspaceId, + isActive: true + }); + + if (!bot) throw new Error('Failed to find bot'); + + // find bot key + botKey = await BotKey.findOne({ + workspace: workspaceId + }).populate<{ sender: IUser }>('sender'); + + if (!botKey) throw new Error('Failed to find bot key'); + + // decrypt bot private key + const privateKey = decryptSymmetric({ + ciphertext: bot.encryptedPrivateKey, + iv: bot.iv, + tag: bot.tag, + key: ENCRYPTION_KEY + }); + + // decrypt workspace key + const key = decryptAsymmetric({ + ciphertext: botKey.encryptedKey, + nonce: botKey.nonce, + publicKey: botKey.sender.publicKey as string, + privateKey + }); + + // decrypt secrets + const secrets = await Secret.find({ + workspace: workspaceId, + environment + }); + + secrets.forEach(secret => { + // KEY, VALUE + const secretKey = decryptSymmetric({ + ciphertext: secret.secretKeyCiphertext, + iv: secret.secretKeyIV, + tag: secret.secretKeyTag, + key + }); + + const secretValue = decryptSymmetric({ + ciphertext: secret.secretValueCiphertext, + iv: secret.secretValueIV, + tag: secret.secretValueTag, + key + }); + + content[secretKey] = secretValue; + }); + + } catch (err) { + console.error('BotService'); + Sentry.setUser(null); + Sentry.captureException(err); + } + + return content; + } +} + +export default BotService; \ No newline at end of file diff --git a/backend/src/services/EventService.ts b/backend/src/services/EventService.ts new file mode 100644 index 000000000..bfceaaa82 --- /dev/null +++ b/backend/src/services/EventService.ts @@ -0,0 +1,75 @@ +import { Bot, IBot, BotSequence } from '../models'; +import * as Sentry from '@sentry/node'; +import ActionService from './ActionService'; + +interface Event { + name: string; + workspaceId: string; + payload: any; +} + +/** + * Class to handle events. TODO: elaborate DOCSTRING. + */ +class EventService { + /** + * Check if any bot sequences exist for event and forward + * bot sequence details to ActionService for execution + * @param {Object} obj + * @param {Event} obj.event - an event + * @param {String} obj.event.name - name of event + * @param {String} obj.event.workspaceId - id of workspace that event is part of + * @param {Object} obj.event.payload - payload of event (depends on event) + */ + static async handleEvent({ event }: { event: Event }): Promise { + let botSequences; + let bot: IBot | null; + try { + + console.log('EventService'); + const { workspaceId } = event; + + bot = await Bot.findOne({ + workspace: workspaceId, + isActive: true + }); + + console.log('A', bot); + // case: bot doesn't exist + if (!bot) { + return; + } + + botSequences = await BotSequence.find({ + bot: bot._id, + event: event.name + }); + + console.log('B', botSequences); + + // case: bot sequences don't exist + if (botSequences.length === 0) return; + + console.log('C'); + + return; + + // // execute event sequences + // botSequences.forEach(botSequence => { + // // sequence.actions + // ActionService.handleAction({ + // action: botSequence.action, + // event, + // bot: bot as IBot + // }); + // }); + + } catch (err) { + console.error('EventService err', err); + Sentry.setUser(null); + Sentry.captureException(err); + } + } +} + +export default EventService; \ No newline at end of file diff --git a/backend/src/services/IntegrationService.ts b/backend/src/services/IntegrationService.ts new file mode 100644 index 000000000..aa437e5c0 --- /dev/null +++ b/backend/src/services/IntegrationService.ts @@ -0,0 +1,93 @@ +import * as Sentry from '@sentry/node'; +import { + Integration, + Bot, + BotSequence +} from '../models'; +import { exchangeCode } from '../integrations'; +import { processOAuthTokenRes2 } from '../helpers/integrationAuth'; +import { + ENV_DEV, + EVENT_PUSH_SECRETS +} from '../variables'; + +/** + * Class to handle integrations + */ +class IntegrationService { + + /** + * Perform OAuth2 code-token exchange for workspace with id [workspaceId] and integration + * named [integration] + * - Store integration access and refresh tokens returned from the OAuth2 code-token exchange + * - Add placeholder inactive integration + * - Create bot sequence for integration + * @param {Object} obj + * @param {String} obj.workspaceId - id of workspace + * @param {String} obj.integration - name of integration + * @param {String} obj.code - code + */ + static async handleOAuthExchange({ + workspaceId, + integration, + code + }: { + workspaceId: string; + integration: string; + code: string; + }) { + console.log('IntegrationService > handleO') + let action; + try { + + const bot = await Bot.find({ + workspace: workspaceId, + isActive: true + }); + + if (!bot) throw new Error('Bot must be enabled for OAuth2 code-token exchange'); + + // exchange code for access and refresh tokens + let res = await exchangeCode({ + integration, + code + }); + + const integrationAuth = await processOAuthTokenRes2({ + workspaceId, + integration, + accessToken: res.accessToken, + accessExpiresAt: res.accessExpiresAt, + refreshToken: res.refreshToken + }); + + await Integration.findOneAndUpdate( + { workspace: workspaceId, integration }, + { + workspace: workspaceId, + environment: ENV_DEV, + isActive: false, + app: null, + integration, + integrationAuth: integrationAuth._id + }, + { upsert: true, new: true } + ); + + // add bot sequence + await BotSequence.findOneAndUpdate({ + bot: bot._id, + name: integration + 'sequence', + event: EVENT_PUSH_SECRETS, + action + }); + } catch (err) { + console.error('IntegrationService error', err); + Sentry.setUser(null); + Sentry.captureException(err); + throw new Error('Failed to handle OAuth2 code-token exchange') + } + } +} + +export default IntegrationService; \ No newline at end of file diff --git a/backend/src/services/index.ts b/backend/src/services/index.ts index 54cdf94f4..c1cf42042 100644 --- a/backend/src/services/index.ts +++ b/backend/src/services/index.ts @@ -1,5 +1,13 @@ import postHogClient from './PostHogClient'; +import BotService from './BotService'; +import EventService from './EventService'; +import ActionService from './ActionService'; +import IntegrationService from './IntegrationService'; export { - postHogClient + postHogClient, + BotService, + EventService, + ActionService, + IntegrationService } \ No newline at end of file diff --git a/backend/src/types/express/index.d.ts b/backend/src/types/express/index.d.ts index 8fdb2b3fe..9a9e81449 100644 --- a/backend/src/types/express/index.d.ts +++ b/backend/src/types/express/index.d.ts @@ -11,6 +11,7 @@ declare global { membershipOrg: any; integration: any; integrationAuth: any; + bot: any; serviceToken: any; accessToken: any; query?: any; diff --git a/backend/src/utils/crypto.ts b/backend/src/utils/crypto.ts index 742e65e80..8172e7fc1 100644 --- a/backend/src/utils/crypto.ts +++ b/backend/src/utils/crypto.ts @@ -2,6 +2,21 @@ import nacl from 'tweetnacl'; import util from 'tweetnacl-util'; import AesGCM from './aes-gcm'; +/** + * Return new base64, NaCl, public-private key pair. + * @returns {Object} obj + * @returns {String} obj.publicKey - base64, NaCl, public key + * @returns {String} obj.privateKey - base64, NaCl, private key + */ +const generateKeyPair = () => { + const pair = nacl.box.keyPair(); + + return ({ + publicKey: util.encodeBase64(pair.publicKey), + privateKey: util.encodeBase64(pair.secretKey) + }); +} + /** * Return assymmetrically encrypted [plaintext] using [publicKey] where * [publicKey] likely belongs to the recipient. @@ -139,6 +154,7 @@ const decryptSymmetric = ({ }; export { + generateKeyPair, encryptAsymmetric, decryptAsymmetric, encryptSymmetric, diff --git a/backend/src/variables.ts b/backend/src/variables.ts deleted file mode 100644 index cdd771b71..000000000 --- a/backend/src/variables.ts +++ /dev/null @@ -1,60 +0,0 @@ -// membership roles -const OWNER = 'owner'; -const ADMIN = 'admin'; -const MEMBER = 'member'; - -// membership statuses -const INVITED = 'invited'; - -// -- organization -const ACCEPTED = 'accepted'; - -// -- workspace -const COMPLETED = 'completed'; -const GRANTED = 'granted'; - -// subscriptions -const PLAN_STARTER = 'starter'; -const PLAN_PRO = 'pro'; - -// secrets -const SECRET_SHARED = 'shared'; -const SECRET_PERSONAL = 'personal'; - -// environments -const ENV_DEV = 'dev'; -const ENV_TESTING = 'test'; -const ENV_STAGING = 'staging'; -const ENV_PROD = 'prod'; -const ENV_SET = new Set([ENV_DEV, ENV_TESTING, ENV_STAGING, ENV_PROD]); - -// integrations -const INTEGRATION_HEROKU = 'heroku'; -const INTEGRATION_NETLIFY = 'netlify'; -const INTEGRATION_SET = new Set([INTEGRATION_HEROKU, INTEGRATION_NETLIFY]); - -// integration types -const INTEGRATION_OAUTH2 = 'oauth2'; - -export { - OWNER, - ADMIN, - MEMBER, - INVITED, - ACCEPTED, - COMPLETED, - GRANTED, - PLAN_STARTER, - PLAN_PRO, - SECRET_SHARED, - SECRET_PERSONAL, - ENV_DEV, - ENV_TESTING, - ENV_STAGING, - ENV_PROD, - ENV_SET, - INTEGRATION_HEROKU, - INTEGRATION_NETLIFY, - INTEGRATION_SET, - INTEGRATION_OAUTH2 -}; diff --git a/backend/src/variables/action.ts b/backend/src/variables/action.ts new file mode 100644 index 000000000..1f913bbe9 --- /dev/null +++ b/backend/src/variables/action.ts @@ -0,0 +1,5 @@ +const ACTION_PUSH_TO_HEROKU = 'pushToHeroku'; + +export { + ACTION_PUSH_TO_HEROKU +} \ No newline at end of file diff --git a/backend/src/variables/environment.ts b/backend/src/variables/environment.ts new file mode 100644 index 000000000..44d7cdbb2 --- /dev/null +++ b/backend/src/variables/environment.ts @@ -0,0 +1,14 @@ +// environments +const ENV_DEV = 'dev'; +const ENV_TESTING = 'test'; +const ENV_STAGING = 'staging'; +const ENV_PROD = 'prod'; +const ENV_SET = new Set([ENV_DEV, ENV_TESTING, ENV_STAGING, ENV_PROD]); + +export { + ENV_DEV, + ENV_TESTING, + ENV_STAGING, + ENV_PROD, + ENV_SET +} \ No newline at end of file diff --git a/backend/src/variables/event.ts b/backend/src/variables/event.ts new file mode 100644 index 000000000..4477e8e02 --- /dev/null +++ b/backend/src/variables/event.ts @@ -0,0 +1,7 @@ +const EVENT_PUSH_SECRETS = 'pushSecrets'; +const EVENT_PULL_SECRETS = 'pullSecrets'; + +export { + EVENT_PUSH_SECRETS, + EVENT_PULL_SECRETS +} \ No newline at end of file diff --git a/backend/src/variables/index.ts b/backend/src/variables/index.ts new file mode 100644 index 000000000..135a45589 --- /dev/null +++ b/backend/src/variables/index.ts @@ -0,0 +1,65 @@ +import { + ENV_DEV, + ENV_TESTING, + ENV_STAGING, + ENV_PROD, + ENV_SET +} from './environment'; +import { + INTEGRATION_HEROKU, + INTEGRATION_NETLIFY, + INTEGRATION_SET, + INTEGRATION_OAUTH2, + OAUTH_TOKEN_URL_HEROKU +} from './integration'; +import { + OWNER, + ADMIN, + MEMBER, + INVITED, + ACCEPTED, + COMPLETED, + GRANTED +} from './organization'; +import { + SECRET_SHARED, + SECRET_PERSONAL +} from './secret'; +import { + PLAN_STARTER, + PLAN_PRO +} from './stripe'; +import { + EVENT_PUSH_SECRETS, + EVENT_PULL_SECRETS +} from './event'; +import { + ACTION_PUSH_TO_HEROKU +} from './action'; + +export { + OWNER, + ADMIN, + MEMBER, + INVITED, + ACCEPTED, + COMPLETED, + GRANTED, + PLAN_STARTER, + PLAN_PRO, + SECRET_SHARED, + SECRET_PERSONAL, + ENV_DEV, + ENV_TESTING, + ENV_STAGING, + ENV_PROD, + ENV_SET, + INTEGRATION_HEROKU, + INTEGRATION_NETLIFY, + INTEGRATION_SET, + INTEGRATION_OAUTH2, + OAUTH_TOKEN_URL_HEROKU, + EVENT_PUSH_SECRETS, + EVENT_PULL_SECRETS, + ACTION_PUSH_TO_HEROKU +}; \ No newline at end of file diff --git a/backend/src/variables/integration.ts b/backend/src/variables/integration.ts new file mode 100644 index 000000000..a63f2299f --- /dev/null +++ b/backend/src/variables/integration.ts @@ -0,0 +1,18 @@ +// integrations +const INTEGRATION_HEROKU = 'heroku'; +const INTEGRATION_NETLIFY = 'netlify'; +const INTEGRATION_SET = new Set([INTEGRATION_HEROKU, INTEGRATION_NETLIFY]); + +// integration types +const INTEGRATION_OAUTH2 = 'oauth2'; + +// integration oauth endpoints +const OAUTH_TOKEN_URL_HEROKU = 'https://id.heroku.com/oauth/token'; + +export { + INTEGRATION_HEROKU, + INTEGRATION_NETLIFY, + INTEGRATION_SET, + INTEGRATION_OAUTH2, + OAUTH_TOKEN_URL_HEROKU +} \ No newline at end of file diff --git a/backend/src/variables/organization.ts b/backend/src/variables/organization.ts new file mode 100644 index 000000000..f91e1f5d3 --- /dev/null +++ b/backend/src/variables/organization.ts @@ -0,0 +1,24 @@ +// membership roles +const OWNER = 'owner'; +const ADMIN = 'admin'; +const MEMBER = 'member'; + +// membership statuses +const INVITED = 'invited'; + +// -- organization +const ACCEPTED = 'accepted'; + +// -- workspace +const COMPLETED = 'completed'; +const GRANTED = 'granted'; + +export { + OWNER, + ADMIN, + MEMBER, + INVITED, + ACCEPTED, + COMPLETED, + GRANTED +} \ No newline at end of file diff --git a/backend/src/variables/secret.ts b/backend/src/variables/secret.ts new file mode 100644 index 000000000..31cbcf951 --- /dev/null +++ b/backend/src/variables/secret.ts @@ -0,0 +1,8 @@ +// secrets +const SECRET_SHARED = 'shared'; +const SECRET_PERSONAL = 'personal'; + +export { + SECRET_SHARED, + SECRET_PERSONAL +} \ No newline at end of file diff --git a/backend/src/variables/stripe.ts b/backend/src/variables/stripe.ts new file mode 100644 index 000000000..ecdbd98ae --- /dev/null +++ b/backend/src/variables/stripe.ts @@ -0,0 +1,7 @@ +const PLAN_STARTER = 'starter'; +const PLAN_PRO = 'pro'; + +export { + PLAN_STARTER, + PLAN_PRO +} \ No newline at end of file From 1757f0d690314cf53c79be5c0b8e951108f533e4 Mon Sep 17 00:00:00 2001 From: Tuan Dang Date: Thu, 8 Dec 2022 23:22:44 -0500 Subject: [PATCH 02/25] Complete v1 loop for bot-based integrations --- .env.example | 1 - backend/src/controllers/botController.ts | 19 +- .../controllers/integrationAuthController.ts | 65 +--- .../src/controllers/integrationController.ts | 7 +- backend/src/helpers/bot.ts | 186 +++++++++- backend/src/helpers/event.ts | 51 +++ backend/src/helpers/integration.ts | 324 ++++++++++++++++++ backend/src/helpers/integrationAuth.ts | 250 -------------- backend/src/helpers/workspace.ts | 8 +- backend/src/integrations/apps.ts | 77 +++++ backend/src/integrations/exchange.ts | 11 +- backend/src/integrations/index.ts | 6 +- backend/src/integrations/refresh.ts | 4 +- backend/src/integrations/sync.ts | 76 ++++ .../src/middleware/requireIntegrationAuth.ts | 8 +- .../requireIntegrationAuthorizationAuth.ts | 7 +- backend/src/models/bot.ts | 6 +- backend/src/models/botSequence.ts | 38 -- backend/src/models/index.ts | 3 - backend/src/routes/bot.ts | 9 +- backend/src/routes/integrationAuth.ts | 6 +- backend/src/services/ActionService.ts | 54 --- backend/src/services/BotService.ts | 146 ++++---- backend/src/services/EventService.ts | 59 +--- backend/src/services/IntegrationService.ts | 160 ++++++--- backend/src/services/index.ts | 2 - backend/src/utils/crypto.ts | 4 +- backend/src/variables/index.ts | 6 +- backend/src/variables/integration.ts | 8 +- .../utilities/secrets/getSecretsForProject.js | 2 +- frontend/pages/api/bot/getBot.js | 27 ++ frontend/pages/api/bot/setBotActiveStatus.js | 31 ++ frontend/pages/integrations/[id].js | 62 ++++ 33 files changed, 1085 insertions(+), 638 deletions(-) create mode 100644 backend/src/helpers/event.ts create mode 100644 backend/src/integrations/apps.ts create mode 100644 backend/src/integrations/sync.ts delete mode 100644 backend/src/models/botSequence.ts delete mode 100644 backend/src/services/ActionService.ts create mode 100644 frontend/pages/api/bot/getBot.js create mode 100644 frontend/pages/api/bot/setBotActiveStatus.js diff --git a/.env.example b/.env.example index 2025622dc..29d63ac9b 100644 --- a/.env.example +++ b/.env.example @@ -47,7 +47,6 @@ SMTP_PASSWORD= # Integration # Optional only if integration is used OAUTH_CLIENT_SECRET_HEROKU= -OAUTH_TOKEN_URL_HEROKU= # Sentry (optional) for monitoring errors SENTRY_DSN= diff --git a/backend/src/controllers/botController.ts b/backend/src/controllers/botController.ts index d4bc90cf3..7819e32df 100644 --- a/backend/src/controllers/botController.ts +++ b/backend/src/controllers/botController.ts @@ -18,7 +18,7 @@ interface BotKey { export const getBotByWorkspaceId = async (req: Request, res: Response) => { let bot; try { - const { workspaceId } = req.body; + const { workspaceId } = req.params; bot = await Bot.findOne({ workspace: workspaceId @@ -58,13 +58,24 @@ export const setBotActiveState = async (req: Request, res: Response) => { if (isActive) { // bot state set to active -> share workspace key with bot - await new BotKey({ + if (!botKey?.encryptedKey || !botKey?.nonce) { + return res.status(400).send({ + message: 'Failed to set bot state to active - missing bot key' + }); + } + + await BotKey.findOneAndUpdate({ + workspace: req.bot.workspace + }, { encryptedKey: botKey.encryptedKey, nonce: botKey.nonce, sender: req.user._id, - receiver: req.bot._id, + bot: req.bot._id, workspace: req.bot.workspace - }).save(); + }, { + upsert: true, + new: true + }); } else { // case: bot state set to inactive -> delete bot's workspace key await BotKey.deleteOne({ diff --git a/backend/src/controllers/integrationAuthController.ts b/backend/src/controllers/integrationAuthController.ts index fcdb40495..829abc7c2 100644 --- a/backend/src/controllers/integrationAuthController.ts +++ b/backend/src/controllers/integrationAuthController.ts @@ -3,15 +3,13 @@ import * as Sentry from '@sentry/node'; import axios from 'axios'; import { readFileSync } from 'fs'; import { IntegrationAuth, Integration } from '../models'; -import { processOAuthTokenRes } from '../helpers/integrationAuth'; import { INTEGRATION_SET, ENV_DEV } from '../variables'; import { OAUTH_CLIENT_SECRET_HEROKU, OAUTH_TOKEN_URL_HEROKU } from '../config'; - import { IntegrationService } from '../services'; +import { getApps } from '../integrations'; /** * Perform OAuth2 code-token exchange as part of integration [integration] for workspace with id [workspaceId] - * Note: integration [integration] must be set up compatible/designed for OAuth2 * @param req * @param res * @returns @@ -21,8 +19,6 @@ export const oAuthExchange = async ( res: Response ) => { try { - // let clientSecret; - const { workspaceId, code, integration } = req.body; if (!INTEGRATION_SET.has(integration)) @@ -33,42 +29,6 @@ export const oAuthExchange = async ( integration, code }); - - // // use correct client secret - // switch (integration) { - // case 'heroku': - // clientSecret = OAUTH_CLIENT_SECRET_HEROKU; - // } - - // // TODO: unfinished - make compatible with other integration types - // const res = await axios.post( // this response may be different for each integration - // OAUTH_TOKEN_URL_HEROKU!, - // new URLSearchParams({ - // grant_type: 'authorization_code', - // code: code, - // client_secret: clientSecret - // } as any) - // ); - - // const integrationAuth = await processOAuthTokenRes({ - // workspaceId, - // integration, - // res - // }); - - // // create or replace integration - // const integrationObj = await Integration.findOneAndUpdate( - // { workspace: workspaceId, integration }, - // { - // workspace: workspaceId, - // environment: ENV_DEV, - // isActive: false, - // app: null, - // integration, - // integrationAuth: integrationAuth._id - // }, - // { upsert: true, new: true } - // ); } catch (err) { Sentry.setUser(null); Sentry.captureException(err); @@ -83,26 +43,25 @@ export const oAuthExchange = async ( }; /** - * Return list of applications allowed for integration with id [integrationAuthId] + * Return list of applications allowed for integration with integration authorization id [integrationAuthId] * @param req * @param res * @returns */ export const getIntegrationAuthApps = async (req: Request, res: Response) => { - // TODO: unfinished - make compatible with other integration types let apps; try { - const res = await axios.get('https://api.heroku.com/apps', { - headers: { - Accept: 'application/vnd.heroku+json; version=3', - Authorization: 'Bearer ' + req.accessToken - } + apps = await getApps({ + integration: req.integrationAuth.integration, + accessToken: req.accessToken }); - - apps = res.data.map((a: any) => ({ - name: a.name - })); - } catch (err) {} + } catch (err) { + Sentry.setUser(null); + Sentry.captureException(err); + return res.status(400).send({ + message: 'Failed to get integration authorization applications' + }); + } return res.status(200).send({ apps diff --git a/backend/src/controllers/integrationController.ts b/backend/src/controllers/integrationController.ts index 3c6ebba56..df3fa5026 100644 --- a/backend/src/controllers/integrationController.ts +++ b/backend/src/controllers/integrationController.ts @@ -49,7 +49,6 @@ export const getIntegrations = async (req: Request, res: Response) => { }); }; -// TODO: deprecate /** * Sync secrets [secrets] to integration with id [integrationId] * @param req @@ -57,7 +56,10 @@ export const getIntegrations = async (req: Request, res: Response) => { * @returns */ export const syncIntegration = async (req: Request, res: Response) => { - // TODO: unfinished - make more versatile to accomodate for other integrations + // NOTE TO ALL DEVS: THIS FUNCTION IS BEING DEPRECATED. IGNORE IT BUT KEEP IT FOR NOW. + + return; + try { const { key, secrets }: { key: Key; secrets: PushSecret[] } = req.body; const symmetricKey = decryptAsymmetric({ @@ -106,6 +108,7 @@ export const syncIntegration = async (req: Request, res: Response) => { */ export const modifyIntegration = async (req: Request, res: Response) => { let integration; + try { const { update } = req.body; diff --git a/backend/src/helpers/bot.ts b/backend/src/helpers/bot.ts index e66fe4489..3235a488d 100644 --- a/backend/src/helpers/bot.ts +++ b/backend/src/helpers/bot.ts @@ -1,9 +1,20 @@ import * as Sentry from '@sentry/node'; import { - Bot + Bot, + BotKey, + Secret, + ISecret, + IUser } from '../models'; -import { generateKeyPair, encryptSymmetric } from '../utils/crypto'; +import { + generateKeyPair, + encryptSymmetric, + decryptSymmetric, + decryptAsymmetric +} from '../utils/crypto'; +import { decryptSecrets } from '../helpers/secret'; import { ENCRYPTION_KEY } from '../config'; +import { SECRET_SHARED } from '../variables'; /** * Create an inactive bot with name [name] for workspace with id [workspaceId] @@ -44,6 +55,175 @@ const createBot = async ({ return bot; } +/** + * Return decrypted secrets for workspace with id [workspaceId] + * and [environment] using bot + * @param {Object} obj + * @param {String} obj.workspaceId - id of workspace + * @param {String} obj.environment - environment + */ +const getSecretsHelper = async ({ + workspaceId, + environment +}: { + workspaceId: string; + environment: string; +}) => { + let content = {} as any; + try { + const key = await getKey({ workspaceId }); + const secrets = await Secret.find({ + workspaceId, + type: SECRET_SHARED + }); + + secrets.forEach((secret: ISecret) => { + const secretKey = decryptSymmetric({ + ciphertext: secret.secretKeyCiphertext, + iv: secret.secretKeyIV, + tag: secret.secretKeyTag, + key + }); + + const secretValue = decryptSymmetric({ + ciphertext: secret.secretValueCiphertext, + iv: secret.secretValueIV, + tag: secret.secretValueTag, + key + }); + + content[secretKey] = secretValue; + }); + } catch (err) { + Sentry.setUser(null); + Sentry.captureException(err); + throw new Error('Failed to get secrets'); + } + + return content; +} + +/** + * Return bot's copy of the workspace key for workspace + * with id [workspaceId] + * @param {Object} obj + * @param {String} obj.workspaceId - id of workspace + * @returns {String} key - decrypted workspace key + */ +const getKey = async ({ workspaceId }: { workspaceId: string }) => { + let key; + try { + const botKey = await BotKey.findOne({ + workspace: workspaceId + }).populate<{ sender: IUser }>('sender', 'publicKey'); + + if (!botKey) throw new Error('Failed to find bot key'); + + const bot = await Bot.findOne({ + workspace: workspaceId + }).select('+encryptedPrivateKey +iv +tag'); + + if (!bot) throw new Error('Failed to find bot'); + if (!bot.isActive) throw new Error('Bot is not active'); + + const privateKeyBot = decryptSymmetric({ + ciphertext: bot.encryptedPrivateKey, + iv: bot.iv, + tag: bot.tag, + key: ENCRYPTION_KEY + }); + + key = decryptAsymmetric({ + ciphertext: botKey.encryptedKey, + nonce: botKey.nonce, + publicKey: botKey.sender.publicKey as string, + privateKey: privateKeyBot + }); + } catch (err) { + Sentry.setUser(null); + Sentry.captureException(err); + throw new Error('Failed to get workspace key'); + } + + return key; +} + +/** + * Return symmetrically encrypted [plaintext] using the + * key for workspace with id [workspaceId] + * @param {Object} obj1 + * @param {String} obj1.workspaceId - id of workspace + * @param {String} obj1.plaintext - plaintext to encrypt + */ +const encryptSymmetricHelper = async ({ + workspaceId, + plaintext +}: { + workspaceId: string; + plaintext: string; +}) => { + + try { + const key = await getKey({ workspaceId }); + const { ciphertext, iv, tag } = encryptSymmetric({ + plaintext, + key + }); + + return ({ + ciphertext, + iv, + tag + }); + } catch (err) { + Sentry.setUser(null); + Sentry.captureException(err); + throw new Error('Failed to perform symmetric encryption with bot'); + } +} +/** + * Return symmetrically decrypted [ciphertext] using the + * key for workspace with id [workspaceId] + * @param {Object} obj + * @param {String} obj.workspaceId - id of workspace + * @param {String} obj.ciphertext - ciphertext to decrypt + * @param {String} obj.iv - iv + * @param {String} obj.tag - tag + */ +const decryptSymmetricHelper = async ({ + workspaceId, + ciphertext, + iv, + tag +}: { + workspaceId: string; + ciphertext: string; + iv: string; + tag: string; +}) => { + let plaintext; + try { + const key = await getKey({ workspaceId }); + const plaintext = decryptSymmetric({ + ciphertext, + iv, + tag, + key + }); + + return plaintext; + } catch (err) { + Sentry.setUser(null); + Sentry.captureException(err); + throw new Error('Failed to perform symmetric decryption with bot'); + } + + return plaintext; +} + export { - createBot + createBot, + getSecretsHelper, + encryptSymmetricHelper, + decryptSymmetricHelper } \ No newline at end of file diff --git a/backend/src/helpers/event.ts b/backend/src/helpers/event.ts new file mode 100644 index 000000000..4128752e5 --- /dev/null +++ b/backend/src/helpers/event.ts @@ -0,0 +1,51 @@ +import { Bot, IBot } from '../models'; +import * as Sentry from '@sentry/node'; +import { EVENT_PUSH_SECRETS } from '../variables'; +import { IntegrationService } from '../services'; + +interface Event { + name: string; + workspaceId: string; + payload: any; +} + +/** + * Handle event [event] + * @param {Object} obj + * @param {Event} obj.event - an event + * @param {String} obj.event.name - name of event + * @param {String} obj.event.workspaceId - id of workspace that event is part of + * @param {Object} obj.event.payload - payload of event (depends on event) + */ +const handleEventHelper = async ({ + event +}: { + event: Event; +}) => { + const { workspaceId } = event; + + // TODO: moduralize bot check into separate function + const bot = await Bot.findOne({ + workspace: workspaceId, + isActive: true + }); + + if (!bot) return; + + try { + switch (event.name) { + case EVENT_PUSH_SECRETS: + IntegrationService.syncIntegrations({ + workspaceId + }); + break; + } + } catch (err) { + Sentry.setUser(null); + Sentry.captureException(err); + } +} + +export { + handleEventHelper +} \ No newline at end of file diff --git a/backend/src/helpers/integration.ts b/backend/src/helpers/integration.ts index e69de29bb..c4caeec9d 100644 --- a/backend/src/helpers/integration.ts +++ b/backend/src/helpers/integration.ts @@ -0,0 +1,324 @@ +import * as Sentry from '@sentry/node'; +import { + Bot, + Integration, + IIntegration, + IntegrationAuth, + IIntegrationAuth +} from '../models'; +import { exchangeCode, exchangeRefresh, syncSecrets } from '../integrations'; +import { BotService, IntegrationService } from '../services'; +import { + ENV_DEV, + EVENT_PUSH_SECRETS +} from '../variables'; + +/** + * Perform OAuth2 code-token exchange for workspace with id [workspaceId] and integration + * named [integration] + * - Store integration access and refresh tokens returned from the OAuth2 code-token exchange + * - Add placeholder inactive integration + * - Create bot sequence for integration + * @param {Object} obj + * @param {String} obj.workspaceId - id of workspace + * @param {String} obj.integration - name of integration + * @param {String} obj.code - code +*/ +const handleOAuthExchangeHelper = async ({ + workspaceId, + integration, + code +}: { + workspaceId: string; + integration: string; + code: string; +}) => { + let action; + let integrationAuth; + try { + const bot = await Bot.findOne({ + workspace: workspaceId, + isActive: true + }); + + if (!bot) throw new Error('Bot must be enabled for OAuth2 code-token exchange'); + + // exchange code for access and refresh tokens + let res = await exchangeCode({ + integration, + code + }); + + integrationAuth = await IntegrationAuth.findOneAndUpdate({ + workspace: workspaceId, + integration + }, { + workspace: workspaceId, + integration + }, { + new: true, + upsert: true + }); + + // set integration auth refresh token + await setIntegrationAuthRefreshHelper({ + integrationAuthId: integrationAuth._id.toString(), + refreshToken: res.refreshToken + }); + + // set integration auth access token + await setIntegrationAuthAccessHelper({ + integrationAuthId: integrationAuth._id.toString(), + accessToken: res.accessToken, + accessExpiresAt: res.accessExpiresAt + }); + + // initializes an integration after exchange + await Integration.findOneAndUpdate( + { workspace: workspaceId, integration }, + { + workspace: workspaceId, + environment: ENV_DEV, + isActive: false, + app: null, + integration, + integrationAuth: integrationAuth._id + }, + { upsert: true, new: true } + ); + } catch (err) { + Sentry.setUser(null); + Sentry.captureException(err); + throw new Error('Failed to handle OAuth2 code-token exchange') + } +} +/** + * Sync/push environment variables in workspace with id [workspaceId] to + * all active integrations for that workspace + * @param {Object} obj + * @param {Object} obj.workspaceId - id of workspace + */ +const syncIntegrationsHelper = async ({ + workspaceId +}: { + workspaceId: string; +}) => { + let integrations; + try { + integrations = await Integration.find({ + workspace: workspaceId, + isActive: true, // TODO: filter so Integrations are ones with non-null apps + app: { $ne: null } + }).populate<{integrationAuth: IIntegrationAuth}>('integrationAuth', 'accessToken'); + + // for each workspace integration, sync/push secrets + // to that integration + for await (const integration of integrations) { + // get workspace, environment (shared) secrets + const secrets = await BotService.getSecrets({ + workspaceId: integration.workspace.toString(), + environment: integration.environment + }); + + // get integration auth access token + const accessToken = await getIntegrationAuthAccessHelper({ + integrationAuthId: integration.integrationAuth._id.toString() + }); + + // sync secrets to integration + await syncSecrets({ + integration: integration.integration, + app: integration.app, + secrets, + accessToken + }); + } + } catch (err) { + Sentry.setUser(null); + Sentry.captureException(err); + throw new Error('Failed to sync secrets to integrations'); + } +} + +/** + * Return decrypted refresh token using the bot's copy + * of the workspace key for workspace belonging to integration auth + * with id [integrationAuthId] + * @param {Object} obj + * @param {String} obj.integrationAuthId - id of integration auth + * @param {String} refreshToken - decrypted refresh token + */ + const getIntegrationAuthRefreshHelper = async ({ integrationAuthId }: { integrationAuthId: string }) => { + let refreshToken; + try { + const integrationAuth = await IntegrationAuth + .findById(integrationAuthId) + .select('+refreshCiphertext +refreshIV +refreshTag'); + + if (!integrationAuth) throw new Error('Failed to find integration auth'); + + refreshToken = await BotService.decryptSymmetric({ + workspaceId: integrationAuth.workspace.toString(), + ciphertext: integrationAuth.refreshCiphertext as string, + iv: integrationAuth.refreshIV as string, + tag: integrationAuth.refreshTag as string + }); + + } catch (err) { + Sentry.setUser(null); + Sentry.captureException(err); + throw new Error('Failed to get integration refresh token'); + } + + return refreshToken; +} + +/** + * Return decrypted access token using the bot's copy + * of the workspace key for workspace belonging to integration auth + * with id [integrationAuthId] + * @param {Object} obj + * @param {String} obj.integrationAuthId - id of integration auth + * @returns {String} accessToken - decrypted access token + */ +const getIntegrationAuthAccessHelper = async ({ integrationAuthId }: { integrationAuthId: string }) => { + let accessToken; + try { + const integrationAuth = await IntegrationAuth + .findById(integrationAuthId) + .select('+accessCiphertext +accessIV +accessTag'); + + if (!integrationAuth) throw new Error('Failed to find integration auth'); + + accessToken = await BotService.decryptSymmetric({ + workspaceId: integrationAuth.workspace.toString(), + ciphertext: integrationAuth.accessCiphertext as string, + iv: integrationAuth.accessIV as string, + tag: integrationAuth.accessTag as string + }); + + if (integrationAuth?.accessExpiresAt && integrationAuth?.refreshCiphertext) { + // there is a access token expiration date + // and refresh token to exchange with the OAuth2 server + + if (integrationAuth.accessExpiresAt < new Date()) { + // access token is expired + const refreshToken = await getIntegrationAuthRefreshHelper({ integrationAuthId }); + accessToken = await exchangeRefresh({ + integration: integrationAuth.integration, + refreshToken + }); + } + } + + } catch (err) { + Sentry.setUser(null); + Sentry.captureException(err); + throw new Error('Failed to get integration access token'); + } + + return accessToken; +} + +/** + * Encrypt refresh token [refreshToken] using the bot's copy + * of the workspace key for workspace belonging to integration auth + * with id [integrationAuthId] and store it + * @param {Object} obj + * @param {String} obj.integrationAuthId - id of integration auth + * @param {String} obj.refreshToken - refresh token + */ +const setIntegrationAuthRefreshHelper = async ({ + integrationAuthId, + refreshToken +}: { + integrationAuthId: string; + refreshToken: string; +}) => { + + let integrationAuth; + try { + integrationAuth = await IntegrationAuth + .findById(integrationAuthId); + + if (!integrationAuth) throw new Error('Failed to find integration auth'); + + const obj = await BotService.encryptSymmetric({ + workspaceId: integrationAuth.workspace.toString(), + plaintext: refreshToken + }); + + integrationAuth = await IntegrationAuth.findOneAndUpdate({ + _id: integrationAuthId + }, { + refreshCiphertext: obj.ciphertext, + refreshIV: obj.iv, + refreshTag: obj.tag + }, { + new: true + }); + } catch (err) { + Sentry.setUser(null); + Sentry.captureException(err); + throw new Error('Failed to set integration auth refresh token'); + } + + return integrationAuth; +} + +/** + * Encrypt access token [accessToken] using the bot's copy + * of the workspace key for workspace belonging to integration auth + * with id [integrationAuthId] and store it along with [accessExpiresAt] + * @param {Object} obj + * @param {String} obj.integrationAuthId - id of integration auth + * @param {String} obj.accessToken - access token + * @param {Date} obj.accessExpiresAt - expiration date of access token + */ +const setIntegrationAuthAccessHelper = async ({ + integrationAuthId, + accessToken, + accessExpiresAt +}: { + integrationAuthId: string; + accessToken: string; + accessExpiresAt: Date; +}) => { + let integrationAuth; + try { + integrationAuth = await IntegrationAuth.findById(integrationAuthId); + + if (!integrationAuth) throw new Error('Failed to find integration auth'); + + const obj = await BotService.encryptSymmetric({ + workspaceId: integrationAuth.workspace.toString(), + plaintext: accessToken + }); + + integrationAuth = await IntegrationAuth.findOneAndUpdate({ + _id: integrationAuthId + }, { + accessCiphertext: obj.ciphertext, + accessIV: obj.iv, + accessTag: obj.tag, + accessExpiresAt + }, { + new: true + }); + } catch (err) { + Sentry.setUser(null); + Sentry.captureException(err); + throw new Error('Failed to save integration auth access token'); + } + + return integrationAuth; +} + +export { + handleOAuthExchangeHelper, + syncIntegrationsHelper, + getIntegrationAuthRefreshHelper, + getIntegrationAuthAccessHelper, + setIntegrationAuthRefreshHelper, + setIntegrationAuthAccessHelper +} \ No newline at end of file diff --git a/backend/src/helpers/integrationAuth.ts b/backend/src/helpers/integrationAuth.ts index dddad5c8a..e69de29bb 100644 --- a/backend/src/helpers/integrationAuth.ts +++ b/backend/src/helpers/integrationAuth.ts @@ -1,250 +0,0 @@ -import * as Sentry from '@sentry/node'; -import axios from 'axios'; -import { IntegrationAuth } from '../models'; -import { encryptSymmetric, decryptSymmetric } from '../utils/crypto'; -import { IIntegrationAuth } from '../models'; -import { - ENCRYPTION_KEY, - OAUTH_CLIENT_SECRET_HEROKU, - OAUTH_TOKEN_URL_HEROKU -} from '../config'; - -/** - * Encrypt access and refresh tokens, compute new access token expiration times [accessExpiresAt], - * and upsert them into the DB for workspace with id [workspaceId] and integration [integration]. - * @param {Object} obj - * @param {String} obj.workspaceId - id of workspace - * @param {String} obj.integration - name of integration - * @param {String} obj.accessToken - access token for integration - * @param {Date} obj.accessExpiresAt - date of expiration for access token - * @param {String} obj.refreshToken - refresh token for integration -*/ -const processOAuthTokenRes2 = async ({ - workspaceId, - integration, - accessToken, - accessExpiresAt, - refreshToken, -}: { - workspaceId: string; - integration: string; - accessToken: string; - accessExpiresAt: Date; - refreshToken: string; -}) => { - - let integrationAuth; - try { - // encrypt refresh + access tokens - const { - ciphertext: refreshCiphertext, - iv: refreshIV, - tag: refreshTag - } = encryptSymmetric({ - plaintext: refreshToken, - key: ENCRYPTION_KEY - }); - - const { - ciphertext: accessCiphertext, - iv: accessIV, - tag: accessTag - } = encryptSymmetric({ - plaintext: accessToken, - key: ENCRYPTION_KEY - }); - - // create or replace integration authorization with encrypted tokens - // and access token expiration date - integrationAuth = await IntegrationAuth.findOneAndUpdate( - { workspace: workspaceId, integration }, - { - workspace: workspaceId, - integration, - refreshCiphertext, - refreshIV, - refreshTag, - accessCiphertext, - accessIV, - accessTag, - accessExpiresAt - }, - { upsert: true, new: true } - ); - - } catch (err) { - Sentry.setUser(null); - Sentry.captureException(err); - throw new Error( - 'Failed to process OAuth2 authorization server token response' - ); - } - - return integrationAuth; -} - -// TODO: deprecate -/** - * Process token exchange and refresh responses from respective OAuth2 authorization servers by - * encrypting access and refresh tokens, computing new access token expiration times [accessExpiresAt], - * and upserting them into the DB for workspace with id [workspaceId] and integration [integration]. - * @param {Object} obj - * @param {String} obj.workspaceId - id of workspace - * @param {String} obj.integration - name of integration (e.g. heroku) - * @param {Object} obj.res - response from OAuth2 authorization server - */ -const processOAuthTokenRes = async ({ - workspaceId, - integration, - res -}: { - workspaceId: string; - integration: string; - res: any; -}): Promise => { - let integrationAuth; - try { - // encrypt refresh + access tokens - const { - ciphertext: refreshCiphertext, - iv: refreshIV, - tag: refreshTag - } = encryptSymmetric({ - plaintext: res.data.refresh_token, - key: ENCRYPTION_KEY - }); - - const { - ciphertext: accessCiphertext, - iv: accessIV, - tag: accessTag - } = encryptSymmetric({ - plaintext: res.data.access_token, - key: ENCRYPTION_KEY - }); - - // compute access token expiration date - const accessExpiresAt = new Date(); - accessExpiresAt.setSeconds( - accessExpiresAt.getSeconds() + res.data.expires_in - ); - - // create or replace integration authorization with encrypted tokens - // and access token expiration date - integrationAuth = await IntegrationAuth.findOneAndUpdate( - { workspace: workspaceId, integration }, - { - workspace: workspaceId, - integration, - refreshCiphertext, - refreshIV, - refreshTag, - accessCiphertext, - accessIV, - accessTag, - accessExpiresAt - }, - { upsert: true, new: true } - ); - } catch (err) { - Sentry.setUser(null); - Sentry.captureException(err); - throw new Error( - 'Failed to process OAuth2 authorization server token response' - ); - } - - return integrationAuth; -}; - -// TODO: deprecate -/** - * Return access token for integration either by decrypting a non-expired access token [accessCiphertext] on - * the integration authorization document or by requesting a new one by decrypting and exchanging the - * refresh token [refreshCiphertext] with the respective OAuth2 authorization server. - * @param {Object} obj - * @param {IIntegrationAuth} obj.integrationAuth - an integration authorization document - * @returns {String} access token - new access token - */ -const getOAuthAccessToken = async ({ - integrationAuth -}: { - integrationAuth: IIntegrationAuth; -}) => { - let accessToken; - try { - const { - refreshCiphertext, - refreshIV, - refreshTag, - accessCiphertext, - accessIV, - accessTag, - accessExpiresAt - } = integrationAuth; - - if ( - refreshCiphertext && - refreshIV && - refreshTag && - accessCiphertext && - accessIV && - accessTag && - accessExpiresAt - ) { - if (accessExpiresAt < new Date()) { - // case: access token expired - // TODO: fetch another access token - - let clientSecret; - switch (integrationAuth.integration) { - case 'heroku': - clientSecret = OAUTH_CLIENT_SECRET_HEROKU; - } - - // record new access token and refresh token - // encrypt refresh + access tokens - const refreshToken = decryptSymmetric({ - ciphertext: refreshCiphertext, - iv: refreshIV, - tag: refreshTag, - key: ENCRYPTION_KEY - }); - - // TODO: make route compatible with other integration types - const res = await axios.post( - OAUTH_TOKEN_URL_HEROKU, // maybe shouldn't be a config variable? - new URLSearchParams({ - grant_type: 'refresh_token', - refresh_token: refreshToken, - client_secret: clientSecret - } as any) - ); - - accessToken = res.data.access_token; - - await processOAuthTokenRes({ - workspaceId: integrationAuth.workspace.toString(), - integration: integrationAuth.integration, - res - }); - } else { - // case: access token still works - accessToken = decryptSymmetric({ - ciphertext: accessCiphertext, - iv: accessIV, - tag: accessTag, - key: ENCRYPTION_KEY - }); - } - } - } catch (err) { - Sentry.setUser(null); - Sentry.captureException(err); - throw new Error('Failed to get OAuth2 access token'); - } - - return accessToken; -}; - -export { processOAuthTokenRes, processOAuthTokenRes2, getOAuthAccessToken }; diff --git a/backend/src/helpers/workspace.ts b/backend/src/helpers/workspace.ts index 34d5a53e7..b43252bf3 100644 --- a/backend/src/helpers/workspace.ts +++ b/backend/src/helpers/workspace.ts @@ -30,10 +30,10 @@ const createWorkspace = async ({ organization: organizationId }).save(); - // const bot = await createBot({ - // name: 'Infisical Bot', - // workspaceId: workspace._id.toString() - // }); + const bot = await createBot({ + name: 'Infisical Bot', + workspaceId: workspace._id.toString() + }); } catch (err) { Sentry.setUser(null); Sentry.captureException(err); diff --git a/backend/src/integrations/apps.ts b/backend/src/integrations/apps.ts new file mode 100644 index 000000000..c40d9f5ee --- /dev/null +++ b/backend/src/integrations/apps.ts @@ -0,0 +1,77 @@ +import axios from 'axios'; +import * as Sentry from '@sentry/node'; +import { + INTEGRATION_HEROKU, + INTEGRATION_HEROKU_APPS_URL +} from '../variables'; + +/** + * Return list of names of apps for integration named [integration] + * @param {Object} obj + * @param {String} obj.integration - name of integration + * @param {String} obj.accessToken - access token for integration + * @returns {Object[]} apps - names of integration apps + * @returns {String} apps.name - name of integration app + */ +const getApps = async ({ + integration, + accessToken +}: { + integration: string; + accessToken: string; +}) => { + let apps; + try { + switch (integration) { + case INTEGRATION_HEROKU: + apps = await getAppsHeroku({ + accessToken + }); + break; + } + + } catch (err) { + Sentry.setUser(null); + Sentry.captureException(err); + throw new Error('Failed to get integration apps'); + } + + return apps; +} + +/** + * Return list of names 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 getAppsHeroku = async ({ + accessToken +}: { + accessToken: string; +}) => { + let apps; + try { + const res = await axios.get(INTEGRATION_HEROKU_APPS_URL, { + headers: { + Accept: 'application/vnd.heroku+json; version=3', + Authorization: `Bearer ${accessToken}` + } + }); + + apps = res.data.map((a: any) => ({ + name: a.name + })); + } catch (err) { + Sentry.setUser(null); + Sentry.captureException(err); + throw new Error('Failed to get Heroku integration apps'); + } + + return apps; +} + +export { + getApps +} \ No newline at end of file diff --git a/backend/src/integrations/exchange.ts b/backend/src/integrations/exchange.ts index 96ca8d26d..1452c4e2c 100644 --- a/backend/src/integrations/exchange.ts +++ b/backend/src/integrations/exchange.ts @@ -2,11 +2,11 @@ import axios from 'axios'; import * as Sentry from '@sentry/node'; import { INTEGRATION_HEROKU, + INTEGRATION_HEROKU_TOKEN_URL, ACTION_PUSH_TO_HEROKU } from '../variables'; import { - OAUTH_CLIENT_SECRET_HEROKU, - OAUTH_TOKEN_URL_HEROKU + OAUTH_CLIENT_SECRET_HEROKU } from '../config'; /** @@ -29,13 +29,13 @@ const exchangeCode = async ({ code: string; }) => { let obj = {} as any; + try { switch (integration) { case INTEGRATION_HEROKU: obj = await exchangeCodeHeroku({ code }); - obj['action'] = ACTION_PUSH_TO_HEROKU; break; } } catch (err) { @@ -63,10 +63,10 @@ const exchangeCodeHeroku = async ({ code: string; }) => { let res: any; - let accessExpiresAt: any; + let accessExpiresAt = new Date(); try { res = await axios.post( - OAUTH_TOKEN_URL_HEROKU!, + INTEGRATION_HEROKU_TOKEN_URL, new URLSearchParams({ grant_type: 'authorization_code', code: code, @@ -78,7 +78,6 @@ const exchangeCodeHeroku = async ({ accessExpiresAt.getSeconds() + res.data.expires_in ); } catch (err) { - console.error('integrationHerokuExchange'); Sentry.setUser(null); Sentry.captureException(err); throw new Error('Failed OAuth2 code-token exchange with Heroku'); diff --git a/backend/src/integrations/index.ts b/backend/src/integrations/index.ts index a97711cb5..3d8e8cc95 100644 --- a/backend/src/integrations/index.ts +++ b/backend/src/integrations/index.ts @@ -1,7 +1,11 @@ import { exchangeCode } from './exchange'; import { exchangeRefresh } from './refresh'; +import { getApps } from './apps'; +import { syncSecrets } from './sync'; export { exchangeCode, - exchangeRefresh + exchangeRefresh, + getApps, + syncSecrets } \ No newline at end of file diff --git a/backend/src/integrations/refresh.ts b/backend/src/integrations/refresh.ts index 730c5a726..b19a6a663 100644 --- a/backend/src/integrations/refresh.ts +++ b/backend/src/integrations/refresh.ts @@ -5,7 +5,7 @@ import { OAUTH_CLIENT_SECRET_HEROKU } from '../config'; import { - OAUTH_TOKEN_URL_HEROKU + INTEGRATION_HEROKU_TOKEN_URL } from '../variables'; /** @@ -55,7 +55,7 @@ const exchangeRefreshHeroku = async ({ let accessToken; try { const res = await axios.post( - OAUTH_TOKEN_URL_HEROKU, + INTEGRATION_HEROKU_TOKEN_URL, new URLSearchParams({ grant_type: 'refresh_token', refresh_token: refreshToken, diff --git a/backend/src/integrations/sync.ts b/backend/src/integrations/sync.ts new file mode 100644 index 000000000..15a61ad66 --- /dev/null +++ b/backend/src/integrations/sync.ts @@ -0,0 +1,76 @@ +import axios from 'axios'; +import * as Sentry from '@sentry/node'; +import { INTEGRATION_HEROKU } from '../variables'; + +/** + * Sync/push [secrets] to [app] in integration named [integration] + * @param {Object} obj + * @param {Object} obj.integration - name of integration + * @param {Object} obj.app - app in integration + * @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 integration + */ +const syncSecrets = async ({ + integration, + app, + secrets, + accessToken +}: { + integration: string; + app: string; + secrets: any; + accessToken: string; +}) => { + try { + switch (integration) { + case INTEGRATION_HEROKU: + await syncSecretsHeroku({ + app, + secrets, + accessToken + }); + break; + } + } catch (err) { + Sentry.setUser(null); + Sentry.captureException(err); + throw new Error('Failed to sync secrets to integration'); + } +} + +/** + * Sync/push [secrets] to Heroku [app] + * @param {Object} obj + * @param {String} obj.app - app in integration + * @param {Object} obj.secrets - secrets to push to integration (object where keys are secret keys and values are secret values) + */ +const syncSecretsHeroku = async ({ + app, + secrets, + accessToken +}: { + app: string; + secrets: any; + accessToken: string; +}) => { + try { + await axios.patch( + `https://api.heroku.com/apps/${app}/config-vars`, + secrets, + { + headers: { + Accept: 'application/vnd.heroku+json; version=3', + Authorization: 'Bearer ' + accessToken + } + } + ); + } catch (err) { + Sentry.setUser(null); + Sentry.captureException(err); + throw new Error('Failed to sync secrets to Heroku'); + } +} + +export { + syncSecrets +} \ No newline at end of file diff --git a/backend/src/middleware/requireIntegrationAuth.ts b/backend/src/middleware/requireIntegrationAuth.ts index dacc0f863..fe653dbc0 100644 --- a/backend/src/middleware/requireIntegrationAuth.ts +++ b/backend/src/middleware/requireIntegrationAuth.ts @@ -1,7 +1,7 @@ import * as Sentry from '@sentry/node'; import { Request, Response, NextFunction } from 'express'; -import { Integration, IntegrationAuth, Membership } from '../models'; -import { getOAuthAccessToken } from '../helpers/integrationAuth'; +import { Bot, Integration, IntegrationAuth, Membership } from '../models'; +import { IntegrationService } from '../services'; import { validateMembership } from '../helpers/membership'; /** @@ -51,7 +51,9 @@ const requireIntegrationAuth = ({ } req.integration = integration; - req.accessToken = await getOAuthAccessToken({ integrationAuth }); + req.accessToken = await IntegrationService.getIntegrationAuthAccess({ + integrationAuthId: integrationAuth._id.toString() + }); return next(); } catch (err) { diff --git a/backend/src/middleware/requireIntegrationAuthorizationAuth.ts b/backend/src/middleware/requireIntegrationAuthorizationAuth.ts index 8fc5d329e..0bf552654 100644 --- a/backend/src/middleware/requireIntegrationAuthorizationAuth.ts +++ b/backend/src/middleware/requireIntegrationAuthorizationAuth.ts @@ -1,7 +1,7 @@ import * as Sentry from '@sentry/node'; import { Request, Response, NextFunction } from 'express'; import { IntegrationAuth } from '../models'; -import { getOAuthAccessToken } from '../helpers/integrationAuth'; +import { IntegrationService } from '../services'; import { validateMembership } from '../helpers/membership'; /** @@ -41,7 +41,10 @@ const requireIntegrationAuthorizationAuth = ({ }); req.integrationAuth = integrationAuth; - req.accessToken = await getOAuthAccessToken({ integrationAuth }); + req.accessToken = await IntegrationService.getIntegrationAuthAccess({ + integrationAuthId: integrationAuth._id.toString() + }); + return next(); } catch (err) { Sentry.setUser(null); diff --git a/backend/src/models/bot.ts b/backend/src/models/bot.ts index f492b31ba..c7e5a9abe 100644 --- a/backend/src/models/bot.ts +++ b/backend/src/models/bot.ts @@ -24,12 +24,12 @@ const botSchema = new Schema( }, isActive: { type: Boolean, - required: true + required: true, + default: false }, publicKey: { type: String, - required: true, - select: false + required: true }, encryptedPrivateKey: { type: String, diff --git a/backend/src/models/botSequence.ts b/backend/src/models/botSequence.ts deleted file mode 100644 index 611214284..000000000 --- a/backend/src/models/botSequence.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { Schema, model, Types } from 'mongoose'; - -export interface IBotSequence { - _id: Types.ObjectId; - bot: Types.ObjectId; - name: string; - event: string; - action: string; -} - -const botSequence = new Schema( - { - bot: { - type: Schema.Types.ObjectId, - ref: 'Bot', - required: true - }, - name: { - type: String, - required: true - }, - event: { - type: String, - required: true - }, - action: { - type: String, - required: true - } - }, - { - timestamps: true - } -); - -const BotSequence = model('BotSequence', botSequence); - -export default BotSequence; \ No newline at end of file diff --git a/backend/src/models/index.ts b/backend/src/models/index.ts index eef3e5fbb..78c38060b 100644 --- a/backend/src/models/index.ts +++ b/backend/src/models/index.ts @@ -1,7 +1,6 @@ import BackupPrivateKey, { IBackupPrivateKey } from './backupPrivateKey'; import Bot, { IBot } from './bot'; import BotKey, { IBotKey } from './botKey'; -import BotSequence, { IBotSequence } from './botSequence'; import IncidentContactOrg, { IIncidentContactOrg } from './incidentContactOrg'; import Integration, { IIntegration } from './integration'; import IntegrationAuth, { IIntegrationAuth } from './integrationAuth'; @@ -23,8 +22,6 @@ export { IBot, BotKey, IBotKey, - BotSequence, - IBotSequence, IncidentContactOrg, IIncidentContactOrg, Integration, diff --git a/backend/src/routes/bot.ts b/backend/src/routes/bot.ts index e9f9380e9..3189bec44 100644 --- a/backend/src/routes/bot.ts +++ b/backend/src/routes/bot.ts @@ -1,6 +1,6 @@ import express from 'express'; const router = express.Router(); -import { body } from 'express-validator'; +import { body, param } from 'express-validator'; import { requireAuth, requireBotAuth, @@ -11,14 +11,13 @@ import { botController } from '../controllers'; import { ADMIN, MEMBER, COMPLETED, GRANTED } from '../variables'; router.get( - '/', + '/:workspaceId', requireAuth, requireWorkspaceAuth({ acceptedRoles: [ADMIN, MEMBER], - acceptedStatuses: [COMPLETED, GRANTED], - location: 'body' + acceptedStatuses: [COMPLETED, GRANTED] }), - body('workspaceId').exists().trim().notEmpty(), + param('workspaceId').exists().trim().notEmpty(), validateRequest, botController.getBotByWorkspaceId ); diff --git a/backend/src/routes/integrationAuth.ts b/backend/src/routes/integrationAuth.ts index dc60c7643..650221f82 100644 --- a/backend/src/routes/integrationAuth.ts +++ b/backend/src/routes/integrationAuth.ts @@ -10,7 +10,7 @@ import { import { ADMIN, MEMBER, GRANTED } from '../variables'; import { integrationAuthController } from '../controllers'; -router.post( // semi-ok +router.post( '/oauth-token', requireAuth, requireWorkspaceAuth({ @@ -25,7 +25,7 @@ router.post( // semi-ok integrationAuthController.oAuthExchange ); -router.get( // not-ok +router.get( '/:integrationAuthId/apps', requireAuth, requireIntegrationAuthorizationAuth({ @@ -37,7 +37,7 @@ router.get( // not-ok integrationAuthController.getIntegrationAuthApps ); -router.delete( // not-ok +router.delete( '/:integrationAuthId', requireAuth, requireIntegrationAuthorizationAuth({ diff --git a/backend/src/services/ActionService.ts b/backend/src/services/ActionService.ts deleted file mode 100644 index 7d6de9dcb..000000000 --- a/backend/src/services/ActionService.ts +++ /dev/null @@ -1,54 +0,0 @@ -import * as Sentry from '@sentry/node'; -import { IBot } from '../models'; -import { ACTION_PUSH_TO_HEROKU } from '../variables'; -import { actionPushToHeroku } from '../actions'; - -interface Event { - name: string; - workspaceId: string; - payload: any; -} - -/** - * Class to handle actions - */ -class ActionService { - /** - * @param {Object} obj - * @param {String} action - name of action to trigger - * @param {Event} event - * @param {String} obj.event.name - name of event - * @param {String} obj.event.workspaceId - id of workspace that event is part of - * @param {Object} obj.event.payload - payload of event (depends on event) - * @param bot - * @returns - */ - static async handleAction({ - action, - event, - bot - }: { - action: string; - event: Event; - bot: IBot; - }) { - try { - switch (action) { - case ACTION_PUSH_TO_HEROKU: - actionPushToHeroku({ - event, - bot - }); - return; - default: - return; - } - } catch (err) { - console.error('EventService err', err); - Sentry.setUser(null); - Sentry.captureException(err); - } - } -} - -export default ActionService; \ No newline at end of file diff --git a/backend/src/services/BotService.ts b/backend/src/services/BotService.ts index a7bbd63bb..792bd8e35 100644 --- a/backend/src/services/BotService.ts +++ b/backend/src/services/BotService.ts @@ -1,19 +1,8 @@ -import { - Bot, - IBot, - BotKey, - IBotKey, - IUser, - Secret -} from '../models'; -import * as Sentry from '@sentry/node'; import { - decryptAsymmetric, - decryptSymmetric -} from '../utils/crypto'; -import { - ENCRYPTION_KEY -} from '../config'; + getSecretsHelper, + encryptSymmetricHelper, + decryptSymmetricHelper +} from '../helpers/bot'; /** * Class to handle bot actions @@ -21,87 +10,72 @@ import { class BotService { /** - * Return decrypted secrets using bot + * Return decrypted secrets for workspace with id [workspaceId] and + * environment [environmen] shared to bot. * @param {Object} obj * @param {String} obj.workspaceId - id of workspace of secrets * @param {String} obj.environment - environment for secrets + * @returns {Object} secretObj - object where keys are secret keys and values are secret values */ - static async decryptSecrets({ + static async getSecrets({ workspaceId, environment }: { workspaceId: string; environment: string; }) { - - let content: any = {}; - let bot; - let botKey; - try { - - // find bot - bot = await Bot.findOne({ - workspace: workspaceId, - isActive: true - }); - - if (!bot) throw new Error('Failed to find bot'); - - // find bot key - botKey = await BotKey.findOne({ - workspace: workspaceId - }).populate<{ sender: IUser }>('sender'); - - if (!botKey) throw new Error('Failed to find bot key'); - - // decrypt bot private key - const privateKey = decryptSymmetric({ - ciphertext: bot.encryptedPrivateKey, - iv: bot.iv, - tag: bot.tag, - key: ENCRYPTION_KEY - }); - - // decrypt workspace key - const key = decryptAsymmetric({ - ciphertext: botKey.encryptedKey, - nonce: botKey.nonce, - publicKey: botKey.sender.publicKey as string, - privateKey - }); - - // decrypt secrets - const secrets = await Secret.find({ - workspace: workspaceId, - environment - }); - - secrets.forEach(secret => { - // KEY, VALUE - const secretKey = decryptSymmetric({ - ciphertext: secret.secretKeyCiphertext, - iv: secret.secretKeyIV, - tag: secret.secretKeyTag, - key - }); - - const secretValue = decryptSymmetric({ - ciphertext: secret.secretValueCiphertext, - iv: secret.secretValueIV, - tag: secret.secretValueTag, - key - }); - - content[secretKey] = secretValue; - }); - - } catch (err) { - console.error('BotService'); - Sentry.setUser(null); - Sentry.captureException(err); - } - - return content; + return await getSecretsHelper({ + workspaceId, + environment + }); + } + + /** + * Return symmetrically encrypted [plaintext] using the + * bot's copy of the workspace key for workspace with id [workspaceId] + * @param {Object} obj + * @param {String} obj.workspaceId - id of workspace + * @param {String} obj.plaintext - plaintext to encrypt + */ + static async encryptSymmetric({ + workspaceId, + plaintext + }: { + workspaceId: string; + plaintext: string; + }) { + return await encryptSymmetricHelper({ + workspaceId, + plaintext + }); + } + + /** + * Return symmetrically decrypted [ciphertext] using the + * bot's copy of the workspace key for workspace with id [workspaceId] + * @param {Object} obj + * @param {String} obj.workspaceId - id of workspace + * @param {String} obj.ciphertext - ciphertext to decrypt + * @param {String} obj.iv - iv + * @param {String} obj.tag - tag + */ + static async decryptSymmetric({ + workspaceId, + ciphertext, + iv, + tag + }: { + workspaceId: string; + ciphertext: string; + iv: string; + tag: string; + }) { + return await decryptSymmetricHelper({ + workspaceId, + ciphertext, + iv, + tag + }); } } diff --git a/backend/src/services/EventService.ts b/backend/src/services/EventService.ts index bfceaaa82..fcbac9ad0 100644 --- a/backend/src/services/EventService.ts +++ b/backend/src/services/EventService.ts @@ -1,6 +1,6 @@ -import { Bot, IBot, BotSequence } from '../models'; +import { Bot, IBot } from '../models'; import * as Sentry from '@sentry/node'; -import ActionService from './ActionService'; +import { handleEventHelper } from '../helpers/event'; interface Event { name: string; @@ -9,12 +9,11 @@ interface Event { } /** - * Class to handle events. TODO: elaborate DOCSTRING. + * Class to handle events. */ class EventService { /** - * Check if any bot sequences exist for event and forward - * bot sequence details to ActionService for execution + * Handle event [event] * @param {Object} obj * @param {Event} obj.event - an event * @param {String} obj.event.name - name of event @@ -22,53 +21,9 @@ class EventService { * @param {Object} obj.event.payload - payload of event (depends on event) */ static async handleEvent({ event }: { event: Event }): Promise { - let botSequences; - let bot: IBot | null; - try { - - console.log('EventService'); - const { workspaceId } = event; - - bot = await Bot.findOne({ - workspace: workspaceId, - isActive: true - }); - - console.log('A', bot); - // case: bot doesn't exist - if (!bot) { - return; - } - - botSequences = await BotSequence.find({ - bot: bot._id, - event: event.name - }); - - console.log('B', botSequences); - - // case: bot sequences don't exist - if (botSequences.length === 0) return; - - console.log('C'); - - return; - - // // execute event sequences - // botSequences.forEach(botSequence => { - // // sequence.actions - // ActionService.handleAction({ - // action: botSequence.action, - // event, - // bot: bot as IBot - // }); - // }); - - } catch (err) { - console.error('EventService err', err); - Sentry.setUser(null); - Sentry.captureException(err); - } + await handleEventHelper({ + event + }); } } diff --git a/backend/src/services/IntegrationService.ts b/backend/src/services/IntegrationService.ts index aa437e5c0..32f5f5a88 100644 --- a/backend/src/services/IntegrationService.ts +++ b/backend/src/services/IntegrationService.ts @@ -1,16 +1,24 @@ import * as Sentry from '@sentry/node'; import { - Integration, - Bot, - BotSequence + Integration } from '../models'; +import { + handleOAuthExchangeHelper, + syncIntegrationsHelper, + getIntegrationAuthRefreshHelper, + getIntegrationAuthAccessHelper, + setIntegrationAuthRefreshHelper, + setIntegrationAuthAccessHelper, +} from '../helpers/integration'; import { exchangeCode } from '../integrations'; -import { processOAuthTokenRes2 } from '../helpers/integrationAuth'; import { ENV_DEV, EVENT_PUSH_SECRETS } from '../variables'; +// should sync stuff be here too? Probably. +// TODO: move bot functions to IntegrationService. + /** * Class to handle integrations */ @@ -36,57 +44,101 @@ class IntegrationService { integration: string; code: string; }) { - console.log('IntegrationService > handleO') - let action; - try { - - const bot = await Bot.find({ - workspace: workspaceId, - isActive: true - }); - - if (!bot) throw new Error('Bot must be enabled for OAuth2 code-token exchange'); - - // exchange code for access and refresh tokens - let res = await exchangeCode({ - integration, - code - }); - - const integrationAuth = await processOAuthTokenRes2({ - workspaceId, - integration, - accessToken: res.accessToken, - accessExpiresAt: res.accessExpiresAt, - refreshToken: res.refreshToken - }); + await handleOAuthExchangeHelper({ + workspaceId, + integration, + code + }); + } + + /** + * Sync/push environment variables in workspace with id [workspaceId] to + * all associated integrations + * @param {Object} obj + * @param {Object} obj.workspaceId - id of workspace + */ + static async syncIntegrations({ + workspaceId + }: { + workspaceId: string; + }) { + return await syncIntegrationsHelper({ + workspaceId + }); + } + + /** + * Return decrypted refresh token for integration auth + * with id [integrationAuthId] + * @param {Object} obj + * @param {String} obj.integrationAuthId - id of integration auth + * @param {String} refreshToken - decrypted refresh token + */ + static async getIntegrationAuthRefresh({ integrationAuthId }: { integrationAuthId: string}) { + return await getIntegrationAuthRefreshHelper({ + integrationAuthId + }); + } + + /** + * Return decrypted access token for integration auth + * with id [integrationAuthId] + * @param {Object} obj + * @param {String} obj.integrationAuthId - id of integration auth + * @param {String} accessToken - decrypted access token + */ + static async getIntegrationAuthAccess({ integrationAuthId }: { integrationAuthId: string}) { + return await getIntegrationAuthAccessHelper({ + integrationAuthId + }); + } - await Integration.findOneAndUpdate( - { workspace: workspaceId, integration }, - { - workspace: workspaceId, - environment: ENV_DEV, - isActive: false, - app: null, - integration, - integrationAuth: integrationAuth._id - }, - { upsert: true, new: true } - ); - - // add bot sequence - await BotSequence.findOneAndUpdate({ - bot: bot._id, - name: integration + 'sequence', - event: EVENT_PUSH_SECRETS, - action - }); - } catch (err) { - console.error('IntegrationService error', err); - Sentry.setUser(null); - Sentry.captureException(err); - throw new Error('Failed to handle OAuth2 code-token exchange') - } + /** + * Encrypt refresh token [refreshToken] using the bot's copy + * of the workspace key for workspace belonging to integration auth + * with id [integrationAuthId] + * @param {Object} obj + * @param {String} obj.integrationAuthId - id of integration auth + * @param {String} obj.refreshToken - refresh token + * @returns {IntegrationAuth} integrationAuth - updated integration auth + */ + static async setIntegrationAuthRefresh({ + integrationAuthId, + refreshToken + }: { + integrationAuthId: string; + refreshToken: string; + }) { + return await setIntegrationAuthRefreshHelper({ + integrationAuthId, + refreshToken + }); + } + + /** + * Encrypt access token [accessToken] using the bot's copy + * of the workspace key for workspace belonging to integration auth + * with id [integrationAuthId] + * @param {Object} obj + * @param {String} obj.integrationAuthId - id of integration auth + * @param {String} obj.accessToken - access token + * @param {String} obj.accessExpiresAt - expiration date of access token + * @returns {IntegrationAuth} - updated integration auth + */ + static async setIntegrationAuthAccess({ + integrationAuthId, + accessToken, + accessExpiresAt + }: { + integrationAuthId: string; + accessToken: string; + accessExpiresAt: Date; + }) { + return await setIntegrationAuthAccessHelper({ + integrationAuthId, + accessToken, + accessExpiresAt + }); } } diff --git a/backend/src/services/index.ts b/backend/src/services/index.ts index c1cf42042..531033f30 100644 --- a/backend/src/services/index.ts +++ b/backend/src/services/index.ts @@ -1,13 +1,11 @@ import postHogClient from './PostHogClient'; import BotService from './BotService'; import EventService from './EventService'; -import ActionService from './ActionService'; import IntegrationService from './IntegrationService'; export { postHogClient, BotService, EventService, - ActionService, IntegrationService } \ No newline at end of file diff --git a/backend/src/utils/crypto.ts b/backend/src/utils/crypto.ts index 8172e7fc1..585dae51d 100644 --- a/backend/src/utils/crypto.ts +++ b/backend/src/utils/crypto.ts @@ -96,7 +96,7 @@ const decryptAsymmetric = ({ * Return symmetrically encrypted [plaintext] using [key]. * @param {Object} obj * @param {String} obj.plaintext - plaintext to encrypt - * @param {String} obj.key - 16-byte hex key + * @param {String} obj.key - hex key */ const encryptSymmetric = ({ plaintext, @@ -129,7 +129,7 @@ const encryptSymmetric = ({ * @param {String} obj.ciphertext - ciphertext to decrypt * @param {String} obj.iv - iv * @param {String} obj.tag - tag - * @param {String} obj.key - 32-byte hex key + * @param {String} obj.key - hex key * */ const decryptSymmetric = ({ diff --git a/backend/src/variables/index.ts b/backend/src/variables/index.ts index 135a45589..9f8bcbd9c 100644 --- a/backend/src/variables/index.ts +++ b/backend/src/variables/index.ts @@ -10,7 +10,8 @@ import { INTEGRATION_NETLIFY, INTEGRATION_SET, INTEGRATION_OAUTH2, - OAUTH_TOKEN_URL_HEROKU + INTEGRATION_HEROKU_TOKEN_URL, + INTEGRATION_HEROKU_APPS_URL } from './integration'; import { OWNER, @@ -58,7 +59,8 @@ export { INTEGRATION_NETLIFY, INTEGRATION_SET, INTEGRATION_OAUTH2, - OAUTH_TOKEN_URL_HEROKU, + INTEGRATION_HEROKU_TOKEN_URL, + INTEGRATION_HEROKU_APPS_URL, EVENT_PUSH_SECRETS, EVENT_PULL_SECRETS, ACTION_PUSH_TO_HEROKU diff --git a/backend/src/variables/integration.ts b/backend/src/variables/integration.ts index a63f2299f..6e4fe45b5 100644 --- a/backend/src/variables/integration.ts +++ b/backend/src/variables/integration.ts @@ -7,12 +7,16 @@ const INTEGRATION_SET = new Set([INTEGRATION_HEROKU, INTEGRATION_NETLIFY]); const INTEGRATION_OAUTH2 = 'oauth2'; // integration oauth endpoints -const OAUTH_TOKEN_URL_HEROKU = 'https://id.heroku.com/oauth/token'; +const INTEGRATION_HEROKU_TOKEN_URL = 'https://id.heroku.com/oauth/token'; + +// integration apps endpoints +const INTEGRATION_HEROKU_APPS_URL = 'https://api.heroku.com/apps'; export { INTEGRATION_HEROKU, INTEGRATION_NETLIFY, INTEGRATION_SET, INTEGRATION_OAUTH2, - OAUTH_TOKEN_URL_HEROKU + INTEGRATION_HEROKU_TOKEN_URL, + INTEGRATION_HEROKU_APPS_URL } \ No newline at end of file diff --git a/frontend/components/utilities/secrets/getSecretsForProject.js b/frontend/components/utilities/secrets/getSecretsForProject.js index 7bdac922f..2e2a4413c 100644 --- a/frontend/components/utilities/secrets/getSecretsForProject.js +++ b/frontend/components/utilities/secrets/getSecretsForProject.js @@ -42,7 +42,7 @@ const getSecretsForProject = async ({ publicKey: file.key.sender.publicKey, privateKey: PRIVATE_KEY, }); - + file.secrets.map((secretPair) => { // decrypt .env file with symmetric key const plainTextKey = decryptSymmetric({ diff --git a/frontend/pages/api/bot/getBot.js b/frontend/pages/api/bot/getBot.js new file mode 100644 index 000000000..465f2d189 --- /dev/null +++ b/frontend/pages/api/bot/getBot.js @@ -0,0 +1,27 @@ +import SecurityClient from "~/utilities/SecurityClient.js"; + +/** + * This function fetches the bot for a project + * @param {Object} obj + * @param {String} obj.workspaceId + * @returns + */ +const getBot = async ({ workspaceId }) => { + return SecurityClient.fetchCall( + "/api/v1/bot/" + workspaceId, + { + method: "GET", + headers: { + "Content-Type": "application/json", + } + } + ).then(async (res) => { + if (res.status == 200) { + return await res.json(); + } else { + console.log("Failed to get bot for project"); + } + }); +}; + +export default getBot; \ No newline at end of file diff --git a/frontend/pages/api/bot/setBotActiveStatus.js b/frontend/pages/api/bot/setBotActiveStatus.js new file mode 100644 index 000000000..8e1b51f24 --- /dev/null +++ b/frontend/pages/api/bot/setBotActiveStatus.js @@ -0,0 +1,31 @@ +import SecurityClient from "~/utilities/SecurityClient.js"; + +/** + * This function fetches the bot for a project + * @param {Object} obj + * @param {String} obj.workspaceId + * @returns + */ +const setBotActiveStatus = async ({ botId, isActive, botKey }) => { + return SecurityClient.fetchCall( + "/api/v1/bot/" + botId + "/active", + { + method: "PATCH", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + isActive, + botKey + }) + } + ).then(async (res) => { + if (res.status == 200) { + return await res.json(); + } else { + console.log("Failed to get bot for project"); + } + }); +}; + +export default setBotActiveStatus; \ No newline at end of file diff --git a/frontend/pages/integrations/[id].js b/frontend/pages/integrations/[id].js index 898882e31..a5603985d 100644 --- a/frontend/pages/integrations/[id].js +++ b/frontend/pages/integrations/[id].js @@ -29,6 +29,14 @@ import getIntegrations from "../api/integrations/GetIntegrations"; import getWorkspaceAuthorizations from "../api/integrations/getWorkspaceAuthorizations"; import getWorkspaceIntegrations from "../api/integrations/getWorkspaceIntegrations"; import startIntegration from "../api/integrations/StartIntegration"; +import getBot from "../api/bot/getBot"; +import setBotActiveStatus from "../api/bot/setBotActiveStatus"; +import getLatestFileKey from "../api/workspace/getLatestFileKey"; + +const { + decryptAssymmetric, + encryptAssymmetric +} = require('../../components/utilities/cryptography/crypto'); const crypto = require("crypto"); @@ -169,6 +177,7 @@ export default function Integrations() { const [authorizations, setAuthorizations] = useState(); const router = useRouter(); const [csrfToken, setCsrfToken] = useState(""); + const [bot, setBot] = useState(null); useEffect(async () => { const tempCSRFToken = crypto.randomBytes(16).toString("hex"); @@ -184,6 +193,12 @@ export default function Integrations() { workspaceId: router.query.id, }); setProjectIntegrations(projectIntegrations); + + const bot = await getBot({ + workspaceId: router.query.id + }); + + setBot(bot.bot); try { const integrationsData = await getIntegrations(); @@ -193,6 +208,50 @@ export default function Integrations() { } }, []); + /** + * Toggle activate/deactivate bot + */ + const handleBotActivate = async () => { + const k = await getLatestFileKey({ workspaceId: router.query.id }); + try { + if (bot) { + let botKey; + if (!bot.isActive) { + // case: bot is active -> deactivate + + const PRIVATE_KEY = localStorage.getItem('PRIVATE_KEY'); + const WORKSPACE_KEY = decryptAssymmetric({ + ciphertext: k.latestKey.encryptedKey, + nonce: k.latestKey.nonce, + publicKey: k.latestKey.sender.publicKey, + privateKey: PRIVATE_KEY + }); + + const { ciphertext, nonce } = encryptAssymmetric({ + plaintext: WORKSPACE_KEY, + publicKey: bot.publicKey, + privateKey: PRIVATE_KEY + }); + + botKey = { + encryptedKey: ciphertext, + nonce + } + } + + // case: bot is not active + const bot2 = await setBotActiveStatus({ + botId: bot._id, + isActive: bot.isActive ? false : true, + botKey + }); + setBot(bot2.bot); + } + } catch (err) { + console.error(err); + } + } + return integrations ? (
@@ -212,6 +271,9 @@ export default function Integrations() {

Current Project Integrations

+

Manage your integrations of Infisical with third-party services.

From a763d8b8edc8522b0da59801a05e4d47061924e8 Mon Sep 17 00:00:00 2001 From: Tuan Dang Date: Thu, 8 Dec 2022 23:55:33 -0500 Subject: [PATCH 03/25] Delete actions folder --- backend/src/actions/index.ts | 7 ----- backend/src/actions/integration.ts | 50 ------------------------------ 2 files changed, 57 deletions(-) delete mode 100644 backend/src/actions/index.ts delete mode 100644 backend/src/actions/integration.ts diff --git a/backend/src/actions/index.ts b/backend/src/actions/index.ts deleted file mode 100644 index 25f85cc38..000000000 --- a/backend/src/actions/index.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { - actionPushToHeroku -} from './integration'; - -export { - actionPushToHeroku -} \ No newline at end of file diff --git a/backend/src/actions/integration.ts b/backend/src/actions/integration.ts deleted file mode 100644 index d9bff4fd1..000000000 --- a/backend/src/actions/integration.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { - Key, - Bot, - IBot, - Integration, - IntegrationAuth -} from '../models'; -import * as Sentry from '@sentry/node'; -import { BotService } from '../services'; - -interface Event { - name: string; - workspaceId: string; - payload: any; -} - -/** - * Push secrets to Heroku - * @param {Object} obj - * @param {Event} obj.event - * @param {IBot} obj.bot - */ -const actionPushToHeroku = ({ - event, - bot -}: { - event: Event, - bot: IBot -}) => { - - // TODO: push secrets in [event] - // event: name, workspaceId, payload (environment, secrets) - try { - - // 1. Bot needs to decrypt their project key - // 2. Bot needs to decrypt secrets - // 3. Query IntegrationAuth for credentials - // 4. Decrypt integration refresh and token - // 5. Query Integration for integration details - // 6. Push to integration - - } catch (err) { - Sentry.setUser(null); - Sentry.captureException(err); - } -} - -export { - actionPushToHeroku -} \ No newline at end of file From 9f82220f4ef54430c0512f1c6ca30671ae4c52bb Mon Sep 17 00:00:00 2001 From: Tuan Dang Date: Fri, 9 Dec 2022 10:59:54 -0500 Subject: [PATCH 04/25] Remove frontend/backend sync operation for envars to integrations in favor of bot --- .../src/controllers/integrationController.ts | 51 ------------- backend/src/routes/integration.ts | 14 ---- .../utilities/secrets/pushKeysIntegration.js | 74 ------------------- .../integrations/ChangeHerokuConfigVars.js | 25 ------- frontend/pages/dashboard/[id].js | 24 ------ frontend/pages/integrations/[id].js | 25 +------ 6 files changed, 2 insertions(+), 211 deletions(-) delete mode 100644 frontend/components/utilities/secrets/pushKeysIntegration.js delete mode 100644 frontend/pages/api/integrations/ChangeHerokuConfigVars.js diff --git a/backend/src/controllers/integrationController.ts b/backend/src/controllers/integrationController.ts index df3fa5026..f6f1aa3a2 100644 --- a/backend/src/controllers/integrationController.ts +++ b/backend/src/controllers/integrationController.ts @@ -49,57 +49,6 @@ export const getIntegrations = async (req: Request, res: Response) => { }); }; -/** - * Sync secrets [secrets] to integration with id [integrationId] - * @param req - * @param res - * @returns - */ -export const syncIntegration = async (req: Request, res: Response) => { - // NOTE TO ALL DEVS: THIS FUNCTION IS BEING DEPRECATED. IGNORE IT BUT KEEP IT FOR NOW. - - return; - - try { - const { key, secrets }: { key: Key; secrets: PushSecret[] } = req.body; - const symmetricKey = decryptAsymmetric({ - ciphertext: key.encryptedKey, - nonce: key.nonce, - publicKey: req.user.publicKey, - privateKey: PRIVATE_KEY - }); - - // decrypt secrets with symmetric key - const content = decryptSecrets({ - secrets, - key: symmetricKey, - format: 'object' - }); - - // TODO: make integration work for other integrations as well - const res = await axios.patch( - `https://api.heroku.com/apps/${req.integration.app}/config-vars`, - content, - { - headers: { - Accept: 'application/vnd.heroku+json; version=3', - Authorization: 'Bearer ' + req.accessToken - } - } - ); - } catch (err) { - Sentry.setUser(null); - Sentry.captureException(err); - return res.status(400).send({ - message: 'Failed to sync secrets with integration' - }); - } - - return res.status(200).send({ - message: 'Successfully synced secrets with integration' - }); -}; - /** * Change environment or name of integration with id [integrationId] * @param req diff --git a/backend/src/routes/integration.ts b/backend/src/routes/integration.ts index 38b774ecb..0808973f7 100644 --- a/backend/src/routes/integration.ts +++ b/backend/src/routes/integration.ts @@ -11,20 +11,6 @@ import { integrationController } from '../controllers'; router.get('/integrations', requireAuth, integrationController.getIntegrations); -router.post( // TODO: deprecate - '/:integrationId/sync', - requireAuth, - requireIntegrationAuth({ - acceptedRoles: [ADMIN, MEMBER], - acceptedStatuses: [GRANTED] - }), - param('integrationId').exists().trim(), - body('key').exists(), - body('secrets').exists(), - validateRequest, - integrationController.syncIntegration -); - router.patch( '/:integrationId', requireAuth, diff --git a/frontend/components/utilities/secrets/pushKeysIntegration.js b/frontend/components/utilities/secrets/pushKeysIntegration.js deleted file mode 100644 index 5b08748e7..000000000 --- a/frontend/components/utilities/secrets/pushKeysIntegration.js +++ /dev/null @@ -1,74 +0,0 @@ -import publicKeyInfical from "~/pages/api/auth/publicKeyInfisical"; -import changeHerokuConfigVars from "~/pages/api/integrations/ChangeHerokuConfigVars"; - -const crypto = require("crypto"); -const { - encryptSymmetric, - encryptAssymmetric, -} = require("../cryptography/crypto"); -const nacl = require("tweetnacl"); -nacl.util = require("tweetnacl-util"); - -const pushKeysIntegration = async ({ obj, integrationId }) => { - const PRIVATE_KEY = localStorage.getItem("PRIVATE_KEY"); - - let randomBytes = crypto.randomBytes(16).toString("hex"); - - const secrets = Object.keys(obj).map((key) => { - // encrypt key - const { - ciphertext: ciphertextKey, - iv: ivKey, - tag: tagKey, - } = encryptSymmetric({ - plaintext: key, - key: randomBytes, - }); - - // encrypt value - const { - ciphertext: ciphertextValue, - iv: ivValue, - tag: tagValue, - } = encryptSymmetric({ - plaintext: obj[key], - key: randomBytes, - }); - - const visibility = "shared"; - - return { - ciphertextKey, - ivKey, - tagKey, - hashKey: crypto.createHash("sha256").update(key).digest("hex"), - ciphertextValue, - ivValue, - tagValue, - hashValue: crypto.createHash("sha256").update(obj[key]).digest("hex"), - type: visibility, - }; - }); - - // obtain public keys of all receivers (i.e. members in workspace) - let publicKeyInfisical = await publicKeyInfical(); - - publicKeyInfisical = (await publicKeyInfisical.json()).publicKey; - - // assymmetrically encrypt key with each receiver public keys - - const { ciphertext, nonce } = encryptAssymmetric({ - plaintext: randomBytes, - publicKey: publicKeyInfisical, - privateKey: PRIVATE_KEY, - }); - - const key = { - encryptedKey: ciphertext, - nonce, - }; - - changeHerokuConfigVars({ integrationId, key, secrets }); -}; - -export default pushKeysIntegration; diff --git a/frontend/pages/api/integrations/ChangeHerokuConfigVars.js b/frontend/pages/api/integrations/ChangeHerokuConfigVars.js deleted file mode 100644 index 118848ae6..000000000 --- a/frontend/pages/api/integrations/ChangeHerokuConfigVars.js +++ /dev/null @@ -1,25 +0,0 @@ -import SecurityClient from "~/utilities/SecurityClient"; - -const changeHerokuConfigVars = ({ integrationId, key, secrets }) => { - return SecurityClient.fetchCall( - "/api/v1/integration/" + integrationId + "/sync", - { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ - key, - secrets, - }), - } - ).then(async (res) => { - if (res.status == 200) { - return res; - } else { - console.log("Failed to sync secrets to Heroku"); - } - }); -}; - -export default changeHerokuConfigVars; diff --git a/frontend/pages/dashboard/[id].js b/frontend/pages/dashboard/[id].js index bec6849ad..aa99a91ed 100644 --- a/frontend/pages/dashboard/[id].js +++ b/frontend/pages/dashboard/[id].js @@ -32,7 +32,6 @@ import DropZone from "~/components/dashboard/DropZone"; import NavHeader from "~/components/navigation/NavHeader"; import getSecretsForProject from "~/components/utilities/secrets/getSecretsForProject"; import pushKeys from "~/components/utilities/secrets/pushKeys"; -import pushKeysIntegration from "~/components/utilities/secrets/pushKeysIntegration"; import guidGenerator from "~/utilities/randomId"; import { envMapping } from "../../public/data/frequentConstants"; @@ -401,29 +400,6 @@ export default function Dashboard() { setButtonReady(false); pushKeys({ obj, workspaceId: router.query.id, env }); - /** - * Check which integrations are active for this project and environment - * If there are any, update environment variables for those integrations - */ - let integrations = await getWorkspaceIntegrations({ - workspaceId: router.query.id, - }); - integrations.map(async (integration) => { - if ( - envMapping[env] == integration.environment && - integration.isActive == true - ) { - let objIntegration = Object.assign( - {}, - ...data.map((row) => ({ [row[2]]: row[3] })) - ); - await pushKeysIntegration({ - obj: objIntegration, - integrationId: integration._id, - }); - } - }); - // If this user has never saved environment variables before, show them a prompt to read docs if (!hasUserEverPushed) { setCheckDocsPopUpVisible(true); diff --git a/frontend/pages/integrations/[id].js b/frontend/pages/integrations/[id].js index a5603985d..de5af1e4d 100644 --- a/frontend/pages/integrations/[id].js +++ b/frontend/pages/integrations/[id].js @@ -14,7 +14,6 @@ import Button from "~/components/basic/buttons/Button"; import ListBox from "~/components/basic/Listbox"; import NavHeader from "~/components/navigation/NavHeader"; import getSecretsForProject from "~/components/utilities/secrets/getSecretsForProject"; -import pushKeysIntegration from "~/components/utilities/secrets/pushKeysIntegration"; import guidGenerator from "~/utilities/randomId"; import { @@ -126,27 +125,7 @@ const Integration = ({ projectIntegration }) => { environment: envMapping[integrationEnvironment], appName: integrationApp, }); - if (result?.status == 200) { - let currentSecrets = await getSecretsForProject({ - env: integrationEnvironment, - setFileState, - setIsKeyAvailable, - setData, - workspaceId: router.query.id, - }); - - let obj = Object.assign( - {}, - ...currentSecrets.map((row) => ({ - [row[2]]: row[3], - })) - ); - await pushKeysIntegration({ - obj, - integrationId: projectIntegration._id, - }); - router.reload(); - } + router.reload(); }} color="mineshaft" size="md" @@ -331,7 +310,7 @@ export default function Integrations() { Date: Sat, 10 Dec 2022 15:43:22 -0500 Subject: [PATCH 05/25] Modify frontend to be compatible with full-loop for bot-based integrations --- .../src/controllers/integrationController.ts | 56 +++++- backend/src/controllers/secretController.ts | 15 +- backend/src/events/secret.ts | 9 +- backend/src/helpers/integration.ts | 24 ++- backend/src/models/integration.ts | 3 +- backend/src/routes/integration.ts | 10 +- .../basic/dialog/ActivateBotDialog.js | 91 +++++++++ frontend/components/utilities/attemptLogin.js | 2 +- .../utilities/secrets/pushKeysIntegration.js | 74 +++++++ .../integrations/ChangeHerokuConfigVars.js | 25 +++ .../api/integrations/StartIntegration.js | 33 ---- .../api/integrations/updateIntegration.js | 42 ++++ frontend/pages/integrations/[id].js | 187 ++++++++++-------- 13 files changed, 413 insertions(+), 158 deletions(-) create mode 100644 frontend/components/basic/dialog/ActivateBotDialog.js create mode 100644 frontend/components/utilities/secrets/pushKeysIntegration.js create mode 100644 frontend/pages/api/integrations/ChangeHerokuConfigVars.js delete mode 100644 frontend/pages/api/integrations/StartIntegration.js create mode 100644 frontend/pages/api/integrations/updateIntegration.js diff --git a/backend/src/controllers/integrationController.ts b/backend/src/controllers/integrationController.ts index f6f1aa3a2..665343bd4 100644 --- a/backend/src/controllers/integrationController.ts +++ b/backend/src/controllers/integrationController.ts @@ -1,11 +1,9 @@ import { Request, Response } from 'express'; import { readFileSync } from 'fs'; import * as Sentry from '@sentry/node'; -import axios from 'axios'; -import { Integration } from '../models'; -import { decryptAsymmetric } from '../utils/crypto'; -import { decryptSecrets } from '../helpers/secret'; -import { PRIVATE_KEY } from '../config'; +import { Integration, Bot, BotKey } from '../models'; +import { EventService } from '../services'; +import { eventPushSecrets } from '../events'; interface Key { encryptedKey: string; @@ -55,26 +53,40 @@ export const getIntegrations = async (req: Request, res: Response) => { * @param res * @returns */ -export const modifyIntegration = async (req: Request, res: Response) => { +export const updateIntegration = async (req: Request, res: Response) => { let integration; try { - const { update } = req.body; + const { app, environment, isActive } = req.body; integration = await Integration.findOneAndUpdate( { _id: req.integration._id }, - update, + { + app, + environment, + isActive + }, { new: true } ); + + if (integration) { + + // trigger event - push secrets + EventService.handleEvent({ + event: eventPushSecrets({ + workspaceId: integration.workspace.toString() + }) + }); + } } catch (err) { Sentry.setUser({ email: req.user.email }); Sentry.captureException(err); return res.status(400).send({ - message: 'Failed to modify integration' + message: 'Failed to update integration' }); } @@ -84,7 +96,8 @@ export const modifyIntegration = async (req: Request, res: Response) => { }; /** - * Delete integration with id [integrationId] + * Delete integration with id [integrationId] and deactivate bot if there are + * no integrations left * @param req * @param res * @returns @@ -97,6 +110,29 @@ export const deleteIntegration = async (req: Request, res: Response) => { deletedIntegration = await Integration.findOneAndDelete({ _id: integrationId }); + + if (!deletedIntegration) throw new Error('Failed to find integration'); + + const integrations = await Integration.find({ + workspace: deletedIntegration.workspace + }); + + if (integrations.length === 0) { + // case: no integrations left, deactivate bot + const bot = await Bot.findOneAndUpdate({ + workspace: deletedIntegration.workspace + }, { + isActive: false + }, { + new: true + }); + + if (bot) { + await BotKey.deleteOne({ + bot: bot._id + }); + } + } } catch (err) { Sentry.setUser({ email: req.user.email }); Sentry.captureException(err); diff --git a/backend/src/controllers/secretController.ts b/backend/src/controllers/secretController.ts index cd91d3dbe..bfd9aee1f 100644 --- a/backend/src/controllers/secretController.ts +++ b/backend/src/controllers/secretController.ts @@ -62,14 +62,6 @@ export const pushSecrets = async (req: Request, res: Response) => { keys }); - // trigger event - EventService.handleEvent({ - event: eventPushSecrets({ - workspaceId, - environment, - secrets - }) - }); if (postHogClient) { postHogClient.capture({ @@ -84,6 +76,13 @@ export const pushSecrets = async (req: Request, res: Response) => { }); } + // trigger event - push secrets + EventService.handleEvent({ + event: eventPushSecrets({ + workspaceId + }) + }); + } catch (err) { Sentry.setUser({ email: req.user.email }); Sentry.captureException(err); diff --git a/backend/src/events/secret.ts b/backend/src/events/secret.ts index 771602d0e..8bb3a86c3 100644 --- a/backend/src/events/secret.ts +++ b/backend/src/events/secret.ts @@ -16,25 +16,18 @@ interface PushSecret { * Return event for pushing secrets * @param {Object} obj * @param {String} obj.workspaceId - id of workspace to push secrets to - * @param {String} obj.environment - environment for secrets - * @param {PushSecret[]} obj.secrets - secrets to push * @returns */ const eventPushSecrets = ({ workspaceId, - environment, - secrets }: { workspaceId: string; - environment: string; - secrets: PushSecret[]; }) => { return ({ name: EVENT_PUSH_SECRETS, workspaceId, payload: { - environment, - secrets + } }); } diff --git a/backend/src/helpers/integration.ts b/backend/src/helpers/integration.ts index c4caeec9d..a9d62f92f 100644 --- a/backend/src/helpers/integration.ts +++ b/backend/src/helpers/integration.ts @@ -73,20 +73,18 @@ const handleOAuthExchangeHelper = async ({ accessExpiresAt: res.accessExpiresAt }); - // initializes an integration after exchange - await Integration.findOneAndUpdate( - { workspace: workspaceId, integration }, - { - workspace: workspaceId, - environment: ENV_DEV, - isActive: false, - app: null, - integration, - integrationAuth: integrationAuth._id - }, - { upsert: true, new: true } - ); + // initialize new integration after exchange + await new Integration({ + workspace: workspaceId, + environment: ENV_DEV, + isActive: false, + app: null, + integration, + integrationAuth: integrationAuth._id + }).save(); + } catch (err) { + console.error('in', err); Sentry.setUser(null); Sentry.captureException(err); throw new Error('Failed to handle OAuth2 code-token exchange') diff --git a/backend/src/models/integration.ts b/backend/src/models/integration.ts index 5e72e8b54..90e3f3019 100644 --- a/backend/src/models/integration.ts +++ b/backend/src/models/integration.ts @@ -37,8 +37,7 @@ const integrationSchema = new Schema( app: { // name of app in provider type: String, - default: null, - required: true + default: null }, integration: { type: String, diff --git a/backend/src/routes/integration.ts b/backend/src/routes/integration.ts index 0808973f7..4d025b1e8 100644 --- a/backend/src/routes/integration.ts +++ b/backend/src/routes/integration.ts @@ -18,10 +18,12 @@ router.patch( acceptedRoles: [ADMIN, MEMBER], acceptedStatuses: [GRANTED] }), - param('integrationId'), - body('update'), + param('integrationId').exists().trim(), + body('app').exists().trim(), + body('environment').exists().trim(), + body('isActive').exists().isBoolean(), validateRequest, - integrationController.modifyIntegration + integrationController.updateIntegration ); router.delete( @@ -31,7 +33,7 @@ router.delete( acceptedRoles: [ADMIN, MEMBER], acceptedStatuses: [GRANTED] }), - param('integrationId'), + param('integrationId').exists().trim(), validateRequest, integrationController.deleteIntegration ); diff --git a/frontend/components/basic/dialog/ActivateBotDialog.js b/frontend/components/basic/dialog/ActivateBotDialog.js new file mode 100644 index 000000000..ece0dcb52 --- /dev/null +++ b/frontend/components/basic/dialog/ActivateBotDialog.js @@ -0,0 +1,91 @@ +import { Fragment } from "react"; +import { Dialog, Transition } from "@headlessui/react"; +import getLatestFileKey from "../../../pages/api/workspace/getLatestFileKey"; +import setBotActiveStatus from "../../../pages/api/bot/setBotActiveStatus"; +import { + decryptAssymmetric, + encryptAssymmetric +} from "../../utilities/cryptography/crypto"; +import Button from "../buttons/Button"; + +const ActivateBotDialog = ({ + isOpen, + closeModal, + selectedIntegrationOption, + handleBotActivate, + handleIntegrationOption +}) => { + + const submit = async () => { + try { + // 1. activate bot + await handleBotActivate(); + + // 2. start integration + await handleIntegrationOption({ + integrationOption: selectedIntegrationOption + }); + } catch (err) { + console.log(err); + } + + closeModal(); + } + + return ( +
+ + + +
+ +
+
+ + + + Grant Infisical access to your secrets + +
+

+ Enabling platform integrations lets Infisical decrypt your secrets so they can be forwarded to the platforms. +

+
+
+
+
+
+
+
+
+
+
+ ); +} + +export default ActivateBotDialog; \ No newline at end of file diff --git a/frontend/components/utilities/attemptLogin.js b/frontend/components/utilities/attemptLogin.js index 33c08b698..bb3a280d8 100644 --- a/frontend/components/utilities/attemptLogin.js +++ b/frontend/components/utilities/attemptLogin.js @@ -74,7 +74,7 @@ const attemptLogin = async ( tag, privateKey, }); - + const userOrgs = await getOrganizations(); const userOrgsData = userOrgs.map((org) => org._id); diff --git a/frontend/components/utilities/secrets/pushKeysIntegration.js b/frontend/components/utilities/secrets/pushKeysIntegration.js new file mode 100644 index 000000000..5b08748e7 --- /dev/null +++ b/frontend/components/utilities/secrets/pushKeysIntegration.js @@ -0,0 +1,74 @@ +import publicKeyInfical from "~/pages/api/auth/publicKeyInfisical"; +import changeHerokuConfigVars from "~/pages/api/integrations/ChangeHerokuConfigVars"; + +const crypto = require("crypto"); +const { + encryptSymmetric, + encryptAssymmetric, +} = require("../cryptography/crypto"); +const nacl = require("tweetnacl"); +nacl.util = require("tweetnacl-util"); + +const pushKeysIntegration = async ({ obj, integrationId }) => { + const PRIVATE_KEY = localStorage.getItem("PRIVATE_KEY"); + + let randomBytes = crypto.randomBytes(16).toString("hex"); + + const secrets = Object.keys(obj).map((key) => { + // encrypt key + const { + ciphertext: ciphertextKey, + iv: ivKey, + tag: tagKey, + } = encryptSymmetric({ + plaintext: key, + key: randomBytes, + }); + + // encrypt value + const { + ciphertext: ciphertextValue, + iv: ivValue, + tag: tagValue, + } = encryptSymmetric({ + plaintext: obj[key], + key: randomBytes, + }); + + const visibility = "shared"; + + return { + ciphertextKey, + ivKey, + tagKey, + hashKey: crypto.createHash("sha256").update(key).digest("hex"), + ciphertextValue, + ivValue, + tagValue, + hashValue: crypto.createHash("sha256").update(obj[key]).digest("hex"), + type: visibility, + }; + }); + + // obtain public keys of all receivers (i.e. members in workspace) + let publicKeyInfisical = await publicKeyInfical(); + + publicKeyInfisical = (await publicKeyInfisical.json()).publicKey; + + // assymmetrically encrypt key with each receiver public keys + + const { ciphertext, nonce } = encryptAssymmetric({ + plaintext: randomBytes, + publicKey: publicKeyInfisical, + privateKey: PRIVATE_KEY, + }); + + const key = { + encryptedKey: ciphertext, + nonce, + }; + + changeHerokuConfigVars({ integrationId, key, secrets }); +}; + +export default pushKeysIntegration; diff --git a/frontend/pages/api/integrations/ChangeHerokuConfigVars.js b/frontend/pages/api/integrations/ChangeHerokuConfigVars.js new file mode 100644 index 000000000..118848ae6 --- /dev/null +++ b/frontend/pages/api/integrations/ChangeHerokuConfigVars.js @@ -0,0 +1,25 @@ +import SecurityClient from "~/utilities/SecurityClient"; + +const changeHerokuConfigVars = ({ integrationId, key, secrets }) => { + return SecurityClient.fetchCall( + "/api/v1/integration/" + integrationId + "/sync", + { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + key, + secrets, + }), + } + ).then(async (res) => { + if (res.status == 200) { + return res; + } else { + console.log("Failed to sync secrets to Heroku"); + } + }); +}; + +export default changeHerokuConfigVars; diff --git a/frontend/pages/api/integrations/StartIntegration.js b/frontend/pages/api/integrations/StartIntegration.js deleted file mode 100644 index a4e8b0b02..000000000 --- a/frontend/pages/api/integrations/StartIntegration.js +++ /dev/null @@ -1,33 +0,0 @@ -import SecurityClient from "~/utilities/SecurityClient"; - -/** - * This route starts the integration after teh default one if gonna set up. - * @param {*} integrationId - * @returns - */ -const startIntegration = ({ integrationId, appName, environment }) => { - return SecurityClient.fetchCall( - "/api/v1/integration/" + integrationId, - { - method: "PATCH", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ - update: { - app: appName, - environment, - isActive: true, - }, - }), - } - ).then(async (res) => { - if (res.status == 200) { - return res; - } else { - console.log("Failed to start an integration"); - } - }); -}; - -export default startIntegration; diff --git a/frontend/pages/api/integrations/updateIntegration.js b/frontend/pages/api/integrations/updateIntegration.js new file mode 100644 index 000000000..db833caf1 --- /dev/null +++ b/frontend/pages/api/integrations/updateIntegration.js @@ -0,0 +1,42 @@ +import SecurityClient from "~/utilities/SecurityClient"; + +/** + * This route starts the integration after teh default one if gonna set up. + * Update integration with id [integrationId] to sync envars from the project's + * [environment] to the integration [app] with active state [isActive] + * @param {Object} obj + * @param {String} obj.integrationId - id of integration + * @param {String} obj.app - name of app + * @param {String} obj.environment - project environment to push secrets from + * @param {Boolean} obj.isActive - active state + * @returns + */ +const updateIntegration = ({ + integrationId, + app, + environment, + isActive +}) => { + return SecurityClient.fetchCall( + "/api/v1/integration/" + integrationId, + { + method: "PATCH", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + app, + environment, + isActive + }), + } + ).then(async (res) => { + if (res.status == 200) { + return res; + } else { + console.log("Failed to start an integration"); + } + }); +}; + +export default updateIntegration; diff --git a/frontend/pages/integrations/[id].js b/frontend/pages/integrations/[id].js index de5af1e4d..a1de765d8 100644 --- a/frontend/pages/integrations/[id].js +++ b/frontend/pages/integrations/[id].js @@ -27,16 +27,16 @@ import getIntegrationApps from "../api/integrations/GetIntegrationApps"; import getIntegrations from "../api/integrations/GetIntegrations"; import getWorkspaceAuthorizations from "../api/integrations/getWorkspaceAuthorizations"; import getWorkspaceIntegrations from "../api/integrations/getWorkspaceIntegrations"; -import startIntegration from "../api/integrations/StartIntegration"; +import updateIntegration from "../api/integrations/updateIntegration"; import getBot from "../api/bot/getBot"; import setBotActiveStatus from "../api/bot/setBotActiveStatus"; import getLatestFileKey from "../api/workspace/getLatestFileKey"; +import ActivateBotDialog from "~/components/basic/dialog/ActivateBotDialog"; const { decryptAssymmetric, encryptAssymmetric } = require('../../components/utilities/cryptography/crypto'); - const crypto = require("crypto"); const Integration = ({ projectIntegration }) => { @@ -120,10 +120,11 @@ const Integration = ({ projectIntegration }) => { -

- Manage your integrations of Infisical with third-party services. -

-
- {projectIntegrations.length > 0 ? ( - projectIntegrations.map((projectIntegration) => ( - - )) - ) : ( -
-
-
- You {"don't"} have any integrations set up yet. When you do, - they will appear here. -
-
- To start, click on any of the options below. It takes 5 clicks - to set up. + setIsActivateBotOpen(false)} + selectedIntegrationOption={selectedIntegrationOption} + handleBotActivate={handleBotActivate} + handleIntegrationOption={handleIntegrationOption} + /> + {projectIntegrations.length > 0 && ( + <> +
+
+

Current Project Integrations

+

+ Manage your integrations of Infisical with third-party services. +

-
+ {projectIntegrations.map((projectIntegration) => ( + + ))} + )} -
+
0 ? 'mt-12' : 'mt-6'} mb-4 text-xl max-w-5xl px-2`}>

Platform & Cloud Integrations

@@ -302,30 +338,24 @@ export default function Integrations() {
{ + if (!["Heroku"].includes(integrations[integration].name)) return; + setSelectedIntegrationOption(integrations[integration]); + integrationOptionPress({ + integrationOption: integrations[integration] + }); + }} key={integrations[integration].name} > - integration logo + /> {integrations[integration].name.split(" ").length > 2 ? (
{integrations[integration].name.split(" ")[0]}
@@ -339,7 +369,6 @@ export default function Integrations() { {integrations[integration].name}
)} -
{["Heroku"].includes(integrations[integration].name) && authorizations .map((authorization) => authorization.integration) From d14ed06d4f391c96566cb97714d3be3f0432318e Mon Sep 17 00:00:00 2001 From: Tuan Dang Date: Sat, 10 Dec 2022 17:46:14 -0500 Subject: [PATCH 06/25] Change frontend integration-bot wording --- .../components/basic/dialog/ActivateBotDialog.js | 2 +- frontend/pages/integrations/[id].js | 15 ++------------- 2 files changed, 3 insertions(+), 14 deletions(-) diff --git a/frontend/components/basic/dialog/ActivateBotDialog.js b/frontend/components/basic/dialog/ActivateBotDialog.js index ece0dcb52..600b61c20 100644 --- a/frontend/components/basic/dialog/ActivateBotDialog.js +++ b/frontend/components/basic/dialog/ActivateBotDialog.js @@ -67,7 +67,7 @@ const ActivateBotDialog = ({

- Enabling platform integrations lets Infisical decrypt your secrets so they can be forwarded to the platforms. + Most cloud integrations require Infisical to be able to decrypt your secrets so they can be forwarded over.

diff --git a/frontend/pages/integrations/[id].js b/frontend/pages/integrations/[id].js index a1de765d8..f68f6a9dc 100644 --- a/frontend/pages/integrations/[id].js +++ b/frontend/pages/integrations/[id].js @@ -316,21 +316,10 @@ export default function Integrations() { )}
0 ? 'mt-12' : 'mt-6'} mb-4 text-xl max-w-5xl px-2`}>
-

Platform & Cloud Integrations

+

Cloud Integrations

- Click on the itegration you want to connect. This will let your - environment variables flow automatically into selected third-party - services. -

-

- Note: during an integration with Heroku, for security reasons, it - is impossible to maintain end-to-end encryption. In theory, this - lets Infisical decrypt yor environment variables. In practice, we - can assure you that this will never be done, and it allows us to - protect your secrets from bad actors online. The core Infisical - service will always stay end-to-end encrypted. With any questions, - reach out support@infisical.com. + Click on an integration to begin syncing secrets to it.

From 3fc6b0c1947a97a5b511dd719facf160747e664a Mon Sep 17 00:00:00 2001 From: Tuan Dang Date: Sun, 11 Dec 2022 11:44:19 -0500 Subject: [PATCH 07/25] Refactoring integrations frontend (cleanup) --- .../integrations/CloudIntegration.tsx | 113 +++++ .../integrations/FrameworkIntegration.tsx | 37 ++ .../components/integrations/Integration.tsx | 140 +++++++ frontend/pages/integrations/[id].js | 389 ++++-------------- 4 files changed, 377 insertions(+), 302 deletions(-) create mode 100644 frontend/components/integrations/CloudIntegration.tsx create mode 100644 frontend/components/integrations/FrameworkIntegration.tsx create mode 100644 frontend/components/integrations/Integration.tsx diff --git a/frontend/components/integrations/CloudIntegration.tsx b/frontend/components/integrations/CloudIntegration.tsx new file mode 100644 index 000000000..4bdad9cf5 --- /dev/null +++ b/frontend/components/integrations/CloudIntegration.tsx @@ -0,0 +1,113 @@ +import React from "react"; +import Image from "next/image"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { + faCheck, + faX, + } from "@fortawesome/free-solid-svg-icons"; + +interface CloudIntegration { + integration: IntegrationOption; + setSelectedIntegrationOption: () => void; + integrationOptionPress: () => void; + deleteIntegrationAuth: () => void; + authorizations: any; +} + +interface IntegrationOption { + name: string; + type: string; + clientId: string; + docsLink: string; +} + +const CloudIntegration = ({ + integration, + setSelectedIntegrationOption, + integrationOptionPress, + deleteIntegrationAuth, + authorizations +}: CloudIntegration) => { + console.log('cio', integration); + return ( +
{ + if (!["Heroku"].includes(integration.name)) return; + setSelectedIntegrationOption(integration); + integrationOptionPress({ + integrationOption: integration + }); + }} + key={integration.name} + > + integration logo + {integration.name.split(" ").length > 2 ? ( +
+
{integration.name.split(" ")[0]}
+
+ {integration.name.split(" ")[1]}{" "} + {integration.name.split(" ")[2]} +
+
+ ) : ( +
+ {integration.name} +
+ )} + {["Heroku"].includes(integration.name) && + authorizations + .map((authorization) => authorization.integration) + .includes(integration.name.toLowerCase()) && ( +
+
{ + deleteIntegrationAuth({ + integrationAuthId: authorizations + .filter( + (authorization) => + authorization.integration == + integration.name.toLowerCase() + ) + .map((authorization) => authorization._id)[0], + }); + router.reload(); + }} + className="cursor-pointer w-max bg-red py-0.5 px-2 rounded-b-md text-xs flex flex-row items-center opacity-0 group-hover:opacity-100 duration-200" + > + + Revoke +
+
+ + Authorized +
+
+ )} + {!["Heroku"].includes(integration.name) && ( +
+
+ Coming Soon +
+
+ )} +
+ ); +} + +export default CloudIntegration; \ No newline at end of file diff --git a/frontend/components/integrations/FrameworkIntegration.tsx b/frontend/components/integrations/FrameworkIntegration.tsx new file mode 100644 index 000000000..2f33c0023 --- /dev/null +++ b/frontend/components/integrations/FrameworkIntegration.tsx @@ -0,0 +1,37 @@ +import React from "react"; +import Image from "next/image"; + +interface Framework { + name: string; + link: string; + image: string; +} + +const FrameworkIntegration = ({ + framework +}: { + framework: Framework; +}) => { + return ( + + ); +} + +export default FrameworkIntegration; diff --git a/frontend/components/integrations/Integration.tsx b/frontend/components/integrations/Integration.tsx new file mode 100644 index 000000000..6fd31ef2d --- /dev/null +++ b/frontend/components/integrations/Integration.tsx @@ -0,0 +1,140 @@ +import React, { useEffect, useState } from "react"; +import { useRouter } from "next/router"; +import { + faArrowRight, + faRotate, + faX, +} from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { + reverseEnvMapping +} from "../../public/data/frequentConstants"; +import updateIntegration from "../../pages/api/integrations/updateIntegration" +import deleteIntegration from "../../pages/api/integrations/DeleteIntegration" +import getIntegrationApps from "../../pages/api/integrations/GetIntegrationApps"; +import Button from "~/components/basic/buttons/Button"; +import ListBox from "~/components/basic/Listbox"; + +interface ProjectIntegration { + app?: string; + environment: string; + integration: string; + integrationAuth: string; + isActive: Boolean; +} + +const Integration = ({ + projectIntegration +}: { + projectIntegration: ProjectIntegration; +}) => { + const [integrationEnvironment, setIntegrationEnvironment] = useState( + reverseEnvMapping[projectIntegration.environment] + ); + const [fileState, setFileState] = useState([]); + const router = useRouter(); + const [apps, setApps] = useState([]); + const [integrationApp, setIntegrationApp] = useState( + projectIntegration.app ? projectIntegration.app : apps[0] + ); + + useEffect(async () => { + const tempHerokuApps = await getIntegrationApps({ + integrationAuthId: projectIntegration.integrationAuth, + }); + const tempHerokuAppNames = tempHerokuApps.map((app) => app.name); + setApps(tempHerokuAppNames); + setIntegrationApp( + projectIntegration.app ? projectIntegration.app : tempHerokuAppNames[0] + ); + }, []); + + return ( +
+
+
+
+
+ ENVIRONMENT +
+ +
+ +
+
+ INTEGRATION +
+
+ {projectIntegration.integration.charAt(0).toUpperCase() + + projectIntegration.integration.slice(1)} +
+
+
+
+ HEROKU APP +
+ +
+
+
+ {projectIntegration.isActive ? ( +
+ +
In Sync
+
+ ) : ( +
+
+
+
+ ); + }; + +export default Integration; \ No newline at end of file diff --git a/frontend/pages/integrations/[id].js b/frontend/pages/integrations/[id].js index f68f6a9dc..d6363d089 100644 --- a/frontend/pages/integrations/[id].js +++ b/frontend/pages/integrations/[id].js @@ -2,208 +2,85 @@ import React, { useEffect, useState } from "react"; import Head from "next/head"; import Image from "next/image"; import { useRouter } from "next/router"; -import { - faArrowRight, - faCheck, - faRotate, - faX, -} from "@fortawesome/free-solid-svg-icons"; -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; - -import Button from "~/components/basic/buttons/Button"; -import ListBox from "~/components/basic/Listbox"; import NavHeader from "~/components/navigation/NavHeader"; -import getSecretsForProject from "~/components/utilities/secrets/getSecretsForProject"; +import Integration from "~/components/integrations/Integration"; +import FrameworkIntegration from "~/components/integrations/FrameworkIntegration"; +import CloudIntegration from "~/components/integrations/CloudIntegration"; import guidGenerator from "~/utilities/randomId"; - import { - envMapping, - frameworks, - reverseEnvMapping + frameworks } from "../../public/data/frequentConstants"; -import deleteIntegration from "../api/integrations/DeleteIntegration"; import deleteIntegrationAuth from "../api/integrations/DeleteIntegrationAuth"; -import getIntegrationApps from "../api/integrations/GetIntegrationApps"; import getIntegrations from "../api/integrations/GetIntegrations"; import getWorkspaceAuthorizations from "../api/integrations/getWorkspaceAuthorizations"; import getWorkspaceIntegrations from "../api/integrations/getWorkspaceIntegrations"; -import updateIntegration from "../api/integrations/updateIntegration"; import getBot from "../api/bot/getBot"; import setBotActiveStatus from "../api/bot/setBotActiveStatus"; import getLatestFileKey from "../api/workspace/getLatestFileKey"; import ActivateBotDialog from "~/components/basic/dialog/ActivateBotDialog"; - const { decryptAssymmetric, encryptAssymmetric } = require('../../components/utilities/cryptography/crypto'); const crypto = require("crypto"); -const Integration = ({ projectIntegration }) => { - const [integrationEnvironment, setIntegrationEnvironment] = useState( - reverseEnvMapping[projectIntegration.environment] - ); - const [fileState, setFileState] = useState([]); - const [data, setData] = useState(); - const [isKeyAvailable, setIsKeyAvailable] = useState(true); - const router = useRouter(); - const [apps, setApps] = useState([]); - const [integrationApp, setIntegrationApp] = useState( - projectIntegration.app ? projectIntegration.app : apps[0] - ); - - useEffect(async () => { - const tempHerokuApps = await getIntegrationApps({ - integrationAuthId: projectIntegration.integrationAuth, - }); - const tempHerokuAppNames = tempHerokuApps.map((app) => app.name); - setApps(tempHerokuAppNames); - setIntegrationApp( - projectIntegration.app ? projectIntegration.app : tempHerokuAppNames[0] - ); - }, []); - - return ( -
-
-
-
-
- ENVIRONMENT -
- -
- -
-
- INTEGRATION -
-
- {projectIntegration.integration.charAt(0).toUpperCase() + - projectIntegration.integration.slice(1)} -
-
-
-
- HEROKU APP -
- -
-
-
- {projectIntegration.isActive ? ( -
- -
In Sync
-
- ) : ( -
-
-
-
- ); -}; - export default function Integrations() { - const [integrations, setIntegrations] = useState({}); + const [integrations, setIntegrations] = useState([]); const [projectIntegrations, setProjectIntegrations] = useState([]); const [authorizations, setAuthorizations] = useState(); - const router = useRouter(); - const [csrfToken, setCsrfToken] = useState(""); const [bot, setBot] = useState(null); const [isActivateBotOpen, setIsActivateBotOpen] = useState(false); const [selectedIntegrationOption, setSelectedIntegrationOption] = useState(null); + const router = useRouter(); + useEffect(async () => { try { - // generate CSRF token for OAuth2 code-token exchange integrations - const tempCSRFToken = crypto.randomBytes(16).toString("hex"); - setCsrfToken(tempCSRFToken); - localStorage.setItem("latestCSRFToken", tempCSRFToken); - + // get integrations authorized for project let projectAuthorizations = await getWorkspaceAuthorizations({ workspaceId: router.query.id, }); setAuthorizations(projectAuthorizations); + // get active/inactive (cloud) integrations for project const projectIntegrations = await getWorkspaceIntegrations({ workspaceId: router.query.id, }); - setProjectIntegrations(projectIntegrations); + // get bot for project const bot = await getBot({ workspaceId: router.query.id }); - setBot(bot.bot); - const integrationsData = await getIntegrations(); - setIntegrations(integrationsData); + // get cloud integration options + let integrationOptions = await getIntegrations(); + integrationOptions = Object + .keys(integrationOptions) + .map(integrationOption => integrationOptions[integrationOption]); + + setIntegrations(integrationOptions); } catch (err) { console.log(err); } }, []); /** - * Toggle activate/deactivate bot + * Activate bot for project by performing the following steps: + * 1. Get the (encrypted) project key + * 2. Decrypt project key with user's private key + * 3. Encrypt project key with bot's public key + * 4. Send encrypted project key to backend and set bot status to active */ const handleBotActivate = async () => { - const key = await getLatestFileKey({ workspaceId: router.query.id }); try { + const key = await getLatestFileKey({ workspaceId: router.query.id }); + if (bot) { - let botKey; - if (!bot.isActive) { - // case: bot is active -> deactivate - + // case: there is a bot const PRIVATE_KEY = localStorage.getItem('PRIVATE_KEY'); + const WORKSPACE_KEY = decryptAssymmetric({ ciphertext: key.latestKey.encryptedKey, nonce: key.latestKey.nonce, @@ -221,16 +98,14 @@ export default function Integrations() { encryptedKey: ciphertext, nonce } + + // case: bot is not active + setBot((await setBotActiveStatus({ + botId: bot._id, + isActive: bot.isActive ? false : true, + botKey + })).bot); } - - // case: bot is not active - const bot2 = await setBotActiveStatus({ - botId: bot._id, - isActive: bot.isActive ? false : true, - botKey - }); - setBot(bot2.bot); - } } catch (err) { console.error(err); } @@ -244,6 +119,10 @@ export default function Integrations() { */ const handleIntegrationOption = async ({ integrationOption }) => { // TODO: modularize + + // generate CSRF token for OAuth2 code-token exchange integrations + const csrfToken = crypto.randomBytes(16).toString("hex"); + switch (integrationOption.name) { case 'Heroku': window.location = `https://id.heroku.com/oauth/authorize?client_id=7b1311a1-1cb2-4938-8adf-f37a399ec41b&response_type=code&scope=write-protected&state=${csrfToken}`; @@ -286,152 +165,58 @@ export default function Integrations() { content="Infisical a simple end-to-end encrypted platform that enables teams to sync and manage their .env files." /> -
-
- - setIsActivateBotOpen(false)} - selectedIntegrationOption={selectedIntegrationOption} - handleBotActivate={handleBotActivate} - handleIntegrationOption={handleIntegrationOption} - /> - {projectIntegrations.length > 0 && ( - <> -
-
-

Current Project Integrations

-
-

- Manage your integrations of Infisical with third-party services. -

-
- {projectIntegrations.map((projectIntegration) => ( - - ))} - - )} -
0 ? 'mt-12' : 'mt-6'} mb-4 text-xl max-w-5xl px-2`}> -
-

Cloud Integrations

+
+ + setIsActivateBotOpen(false)} + selectedIntegrationOption={selectedIntegrationOption} + handleBotActivate={handleBotActivate} + handleIntegrationOption={handleIntegrationOption} + /> + {projectIntegrations.length > 0 && ( + <> +
+

Current Project Integrations

+

+ Manage your integrations of Infisical with third-party services. +

-

- Click on an integration to begin syncing secrets to it. -

-
-
- {Object.keys(integrations).map((integration) => ( -
{ - if (!["Heroku"].includes(integrations[integration].name)) return; - setSelectedIntegrationOption(integrations[integration]); - integrationOptionPress({ - integrationOption: integrations[integration] - }); - }} - key={integrations[integration].name} - > - integration logo - {integrations[integration].name.split(" ").length > 2 ? ( -
-
{integrations[integration].name.split(" ")[0]}
-
- {integrations[integration].name.split(" ")[1]}{" "} - {integrations[integration].name.split(" ")[2]} -
-
- ) : ( -
- {integrations[integration].name} -
- )} - {["Heroku"].includes(integrations[integration].name) && - authorizations - .map((authorization) => authorization.integration) - .includes(integrations[integration].name.toLowerCase()) && ( -
-
{ - deleteIntegrationAuth({ - integrationAuthId: authorizations - .filter( - (authorization) => - authorization.integration == - integrations[integration].name.toLowerCase() - ) - .map((authorization) => authorization._id)[0], - }); - router.reload(); - }} - className="cursor-pointer w-max bg-red py-0.5 px-2 rounded-b-md text-xs flex flex-row items-center opacity-0 group-hover:opacity-100 duration-200" - > - - Revoke -
-
- - Authorized -
-
- )} - {!["Heroku"].includes(integrations[integration].name) && ( -
-
- Coming Soon -
-
- )} -
+ {projectIntegrations.map((projectIntegration) => ( + ))} -
-
-
-

Framework Integrations

-
-

- Click on a framework to get the setup instructions. -

-
+ + )} +
0 ? 'mt-12' : 'mt-6'} text-xl max-w-5xl px-2`}> +

Cloud Integrations

+

+ Click on an integration to begin syncing secrets to it. +

+
+
+ {integrations.map((integration) => ( + + ))} +
+
+

Framework Integrations

+

+ Click on a framework to get the setup instructions. +

+
+
+ {frameworks.map((framework) => ( + + ))}
From d410b42a34444d29c72a9b11f9578b3fb0ecd31b Mon Sep 17 00:00:00 2001 From: Tuan Dang Date: Sun, 11 Dec 2022 12:21:49 -0500 Subject: [PATCH 08/25] Fix more merge conflicts and continue cleaning up integrations frontend --- .../integrations/CloudIntegration.tsx | 1 - .../utilities/secrets/getSecretsForProject.ts | 5 -- .../pages/api/bot/{getBot.js => getBot.ts} | 10 ++- frontend/pages/api/bot/setBotActiveStatus.js | 31 --------- frontend/pages/api/bot/setBotActiveStatus.ts | 46 +++++++++++++ frontend/pages/dashboard/[id].js | 51 -------------- frontend/pages/heroku.js | 6 ++ frontend/pages/integrations/[id].js | 66 ++++++++++--------- 8 files changed, 95 insertions(+), 121 deletions(-) rename frontend/pages/api/bot/{getBot.js => getBot.ts} (71%) delete mode 100644 frontend/pages/api/bot/setBotActiveStatus.js create mode 100644 frontend/pages/api/bot/setBotActiveStatus.ts diff --git a/frontend/components/integrations/CloudIntegration.tsx b/frontend/components/integrations/CloudIntegration.tsx index 4bdad9cf5..f3e598852 100644 --- a/frontend/components/integrations/CloudIntegration.tsx +++ b/frontend/components/integrations/CloudIntegration.tsx @@ -28,7 +28,6 @@ const CloudIntegration = ({ deleteIntegrationAuth, authorizations }: CloudIntegration) => { - console.log('cio', integration); return (
{ -======= file.secrets.map((secretPair: any) => { ->>>>>>> 158c51ff3cbcd9a8eab6c44e728232d79a1cbab9:frontend/components/utilities/secrets/getSecretsForProject.ts // decrypt .env file with symmetric key const plainTextKey = decryptSymmetric({ ciphertext: secretPair.secretKey.ciphertext, diff --git a/frontend/pages/api/bot/getBot.js b/frontend/pages/api/bot/getBot.ts similarity index 71% rename from frontend/pages/api/bot/getBot.js rename to frontend/pages/api/bot/getBot.ts index 465f2d189..6621dbe2f 100644 --- a/frontend/pages/api/bot/getBot.js +++ b/frontend/pages/api/bot/getBot.ts @@ -1,4 +1,8 @@ -import SecurityClient from "~/utilities/SecurityClient.js"; +import SecurityClient from "~/utilities/SecurityClient"; + +interface Props { + workspaceId: string; +} /** * This function fetches the bot for a project @@ -6,7 +10,7 @@ import SecurityClient from "~/utilities/SecurityClient.js"; * @param {String} obj.workspaceId * @returns */ -const getBot = async ({ workspaceId }) => { +const getBot = async ({ workspaceId }: Props) => { return SecurityClient.fetchCall( "/api/v1/bot/" + workspaceId, { @@ -16,7 +20,7 @@ const getBot = async ({ workspaceId }) => { } } ).then(async (res) => { - if (res.status == 200) { + if (res && res.status == 200) { return await res.json(); } else { console.log("Failed to get bot for project"); diff --git a/frontend/pages/api/bot/setBotActiveStatus.js b/frontend/pages/api/bot/setBotActiveStatus.js deleted file mode 100644 index 8e1b51f24..000000000 --- a/frontend/pages/api/bot/setBotActiveStatus.js +++ /dev/null @@ -1,31 +0,0 @@ -import SecurityClient from "~/utilities/SecurityClient.js"; - -/** - * This function fetches the bot for a project - * @param {Object} obj - * @param {String} obj.workspaceId - * @returns - */ -const setBotActiveStatus = async ({ botId, isActive, botKey }) => { - return SecurityClient.fetchCall( - "/api/v1/bot/" + botId + "/active", - { - method: "PATCH", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ - isActive, - botKey - }) - } - ).then(async (res) => { - if (res.status == 200) { - return await res.json(); - } else { - console.log("Failed to get bot for project"); - } - }); -}; - -export default setBotActiveStatus; \ No newline at end of file diff --git a/frontend/pages/api/bot/setBotActiveStatus.ts b/frontend/pages/api/bot/setBotActiveStatus.ts new file mode 100644 index 000000000..e85ed3c77 --- /dev/null +++ b/frontend/pages/api/bot/setBotActiveStatus.ts @@ -0,0 +1,46 @@ +import SecurityClient from "~/utilities/SecurityClient"; + +interface BotKey { + encryptedKey: string; + nonce: string; +} + +interface Props { + botId: string; + isActive: Boolean; + botKey: BotKey; +} + +/** + * This function sets the active status of a bot and shares a copy of + * the project key (encrypted under the bot's public key) with the + * project's bot + * @param {Object} obj + * @param {String} obj.botId + * @param {String} obj.isActive + * @param {Object} obj.botKey + * @returns + */ +const setBotActiveStatus = async ({ botId, isActive, botKey }: Props) => { + return SecurityClient.fetchCall( + "/api/v1/bot/" + botId + "/active", + { + method: "PATCH", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + isActive, + botKey + }) + } + ).then(async (res) => { + if (res && res.status == 200) { + return await res.json(); + } else { + console.log("Failed to get bot for project"); + } + }); +}; + +export default setBotActiveStatus; \ No newline at end of file diff --git a/frontend/pages/dashboard/[id].js b/frontend/pages/dashboard/[id].js index c6cd4c98a..050193b1b 100644 --- a/frontend/pages/dashboard/[id].js +++ b/frontend/pages/dashboard/[id].js @@ -18,30 +18,6 @@ import { faPerson, faPlus, faShuffle, -<<<<<<< HEAD - faX, -} from "@fortawesome/free-solid-svg-icons"; -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { Menu, Transition } from "@headlessui/react"; - -import Button from "~/components/basic/buttons/Button"; -import ListBox from "~/components/basic/Listbox"; -import BottonRightPopup from "~/components/basic/popups/BottomRightPopup"; -import { useNotificationContext } from "~/components/context/Notifications/NotificationProvider"; -import DashboardInputField from "~/components/dashboard/DashboardInputField"; -import DropZone from "~/components/dashboard/DropZone"; -import NavHeader from "~/components/navigation/NavHeader"; -import getSecretsForProject from "~/components/utilities/secrets/getSecretsForProject"; -import pushKeys from "~/components/utilities/secrets/pushKeys"; -import guidGenerator from "~/utilities/randomId"; - -import { envMapping } from "../../public/data/frequentConstants"; -import getWorkspaceIntegrations from "../api/integrations/getWorkspaceIntegrations"; -import getUser from "../api/user/getUser"; -import checkUserAction from "../api/userActions/checkUserAction"; -import registerUserAction from "../api/userActions/registerUserAction"; -import getWorkspaces from "../api/workspace/getWorkspaces"; -======= faX } from '@fortawesome/free-solid-svg-icons'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; @@ -65,7 +41,6 @@ import getUser from '../api/user/getUser'; import checkUserAction from '../api/userActions/checkUserAction'; import registerUserAction from '../api/userActions/registerUserAction'; import getWorkspaces from '../api/workspace/getWorkspaces'; ->>>>>>> 158c51ff3cbcd9a8eab6c44e728232d79a1cbab9 /** * This component represent a single row for an environemnt variable on the dashboard @@ -426,32 +401,6 @@ export default function Dashboard() { setButtonReady(false); pushKeys({ obj, workspaceId: router.query.id, env }); -<<<<<<< HEAD -======= - /** - * Check which integrations are active for this project and environment - * If there are any, update environment variables for those integrations - */ - let integrations = await getWorkspaceIntegrations({ - workspaceId: router.query.id - }); - integrations.map(async (integration) => { - if ( - envMapping[env] == integration.environment && - integration.isActive == true - ) { - let objIntegration = Object.assign( - {}, - ...data.map((row) => ({ [row[2]]: row[3] })) - ); - await pushKeysIntegration({ - obj: objIntegration, - integrationId: integration._id - }); - } - }); - ->>>>>>> 158c51ff3cbcd9a8eab6c44e728232d79a1cbab9 // If this user has never saved environment variables before, show them a prompt to read docs if (!hasUserEverPushed) { setCheckDocsPopUpVisible(true); diff --git a/frontend/pages/heroku.js b/frontend/pages/heroku.js index 82947b1b9..42e915ba4 100644 --- a/frontend/pages/heroku.js +++ b/frontend/pages/heroku.js @@ -5,6 +5,7 @@ const queryString = require("query-string"); import AuthorizeIntegration from "./api/integrations/authorizeIntegration"; export default function Heroku() { + console.log('HEROKU PAGE'); const router = useRouter(); const parsedUrl = queryString.parse(router.asPath.split("?")[1]); const code = parsedUrl.code; @@ -16,7 +17,11 @@ export default function Heroku() { // eslint-disable-next-line react-hooks/exhaustive-deps useEffect(async () => { try { + console.log('A'); + console.log(state); + console.log(localStorage.getItem('latestCSRFToken')); if (state == localStorage.getItem("latestCSRFToken")) { + console.log('B'); await AuthorizeIntegration({ workspaceId: localStorage.getItem("projectData.id"), code, @@ -25,6 +30,7 @@ export default function Heroku() { router.push("/integrations/" + localStorage.getItem("projectData.id")); } } catch (error) { + console.error(error); console.log("Error - Not logged in yet"); } // eslint-disable-next-line react-hooks/exhaustive-deps diff --git a/frontend/pages/integrations/[id].js b/frontend/pages/integrations/[id].js index d6363d089..a301deda7 100644 --- a/frontend/pages/integrations/[id].js +++ b/frontend/pages/integrations/[id].js @@ -74,38 +74,38 @@ export default function Integrations() { * 4. Send encrypted project key to backend and set bot status to active */ const handleBotActivate = async () => { + let botKey; try { - const key = await getLatestFileKey({ workspaceId: router.query.id }); if (bot) { // case: there is a bot - const PRIVATE_KEY = localStorage.getItem('PRIVATE_KEY'); - - const WORKSPACE_KEY = decryptAssymmetric({ - ciphertext: key.latestKey.encryptedKey, - nonce: key.latestKey.nonce, - publicKey: key.latestKey.sender.publicKey, - privateKey: PRIVATE_KEY - }); + const key = await getLatestFileKey({ workspaceId: router.query.id }); + const PRIVATE_KEY = localStorage.getItem('PRIVATE_KEY'); + + const WORKSPACE_KEY = decryptAssymmetric({ + ciphertext: key.latestKey.encryptedKey, + nonce: key.latestKey.nonce, + publicKey: key.latestKey.sender.publicKey, + privateKey: PRIVATE_KEY + }); - const { ciphertext, nonce } = encryptAssymmetric({ - plaintext: WORKSPACE_KEY, - publicKey: bot.publicKey, - privateKey: PRIVATE_KEY - }); + const { ciphertext, nonce } = encryptAssymmetric({ + plaintext: WORKSPACE_KEY, + publicKey: bot.publicKey, + privateKey: PRIVATE_KEY + }); - botKey = { - encryptedKey: ciphertext, - nonce - } - - // case: bot is not active - setBot((await setBotActiveStatus({ - botId: bot._id, - isActive: bot.isActive ? false : true, - botKey - })).bot); + botKey = { + encryptedKey: ciphertext, + nonce } + + setBot((await setBotActiveStatus({ + botId: bot._id, + isActive: bot.isActive ? false : true, + botKey + })).bot); + } } catch (err) { console.error(err); } @@ -115,6 +115,9 @@ export default function Integrations() { * Start integration for a given integration option [integrationOption] * @param {Object} obj * @param {Object} obj.integrationOption - an integration option + * @param {String} obj.name + * @param {String} obj.type + * @param {String} obj.docsLink * @returns */ const handleIntegrationOption = async ({ integrationOption }) => { @@ -122,20 +125,23 @@ export default function Integrations() { // generate CSRF token for OAuth2 code-token exchange integrations const csrfToken = crypto.randomBytes(16).toString("hex"); + localStorage.setItem('latestCSRFToken', csrfToken); switch (integrationOption.name) { case 'Heroku': window.location = `https://id.heroku.com/oauth/authorize?client_id=7b1311a1-1cb2-4938-8adf-f37a399ec41b&response_type=code&scope=write-protected&state=${csrfToken}`; return; } - } - + /** - * Call [handleIntegrationOption] if bot is active, else open dialog for user to grant - * permission to share secretes with Infisical prior to starting any integration + * Open dialog to activate bot if bot is not active. + * Otherwise, start integration [integrationOption] * @param {Object} obj - * @param {String} obj.integrationOption - an integration option + * @param {Object} obj.integrationOption - an integration option + * @param {String} obj.name + * @param {String} obj.type + * @param {String} obj.docsLink * @returns */ const integrationOptionPress = ({ integrationOption }) => { From 74d5586005c8081e5d29ae457e38d7960a07a37b Mon Sep 17 00:00:00 2001 From: Tuan Dang Date: Sun, 11 Dec 2022 15:20:10 -0500 Subject: [PATCH 09/25] Modularize integration sections in frontend --- .../integrations/CloudIntegration.tsx | 4 +- .../integrations/CloudIntegrationSection.tsx | 51 ++++++++++++++++ .../FrameworkIntegrationSection.tsx | 33 ++++++++++ .../ProjectIntegrationSection.tsx | 30 ++++++++++ frontend/pages/integrations/[id].js | 60 +++++-------------- 5 files changed, 132 insertions(+), 46 deletions(-) create mode 100644 frontend/components/integrations/CloudIntegrationSection.tsx create mode 100644 frontend/components/integrations/FrameworkIntegrationSection.tsx create mode 100644 frontend/components/integrations/ProjectIntegrationSection.tsx diff --git a/frontend/components/integrations/CloudIntegration.tsx b/frontend/components/integrations/CloudIntegration.tsx index f3e598852..e59d58a50 100644 --- a/frontend/components/integrations/CloudIntegration.tsx +++ b/frontend/components/integrations/CloudIntegration.tsx @@ -6,7 +6,7 @@ import { faX, } from "@fortawesome/free-solid-svg-icons"; -interface CloudIntegration { +interface Props { integration: IntegrationOption; setSelectedIntegrationOption: () => void; integrationOptionPress: () => void; @@ -27,7 +27,7 @@ const CloudIntegration = ({ integrationOptionPress, deleteIntegrationAuth, authorizations -}: CloudIntegration) => { +}: Props) => { return (
void; + integrationOptionPress: () => void; + deleteIntegrationAuth: () => void; + authorizations: any; +} + +const CloudIntegrationSection = ({ + projectIntegrations, + integrations, + setSelectedIntegrationOption, + integrationOptionPress, + deleteIntegrationAuth, + authorizations +}: Props) => { + return ( + <> +
0 ? 'mt-12' : 'mt-6'} text-xl max-w-5xl px-2`}> +

Cloud Integrations

+

+ Click on an integration to begin syncing secrets to it. +

+
+
+ {integrations.map((integration) => ( + + ))} +
+ + ); +} + +export default CloudIntegrationSection; \ No newline at end of file diff --git a/frontend/components/integrations/FrameworkIntegrationSection.tsx b/frontend/components/integrations/FrameworkIntegrationSection.tsx new file mode 100644 index 000000000..0884d9705 --- /dev/null +++ b/frontend/components/integrations/FrameworkIntegrationSection.tsx @@ -0,0 +1,33 @@ +import React from "react"; +import FrameworkIntegration from "./FrameworkIntegration"; + +interface Framework { + name: string; + image: string; + link: string; +} + +interface Props { + framework: Framework +} + +const FrameworkIntegrationSection = ({ frameworks }: Props) => { + return ( + <> +
+

Framework Integrations

+

+ Click on a framework to get the setup instructions. +

+
+
+ {frameworks.map((framework) => ( + + ))} +
+ + ); +} + +export default FrameworkIntegrationSection; + diff --git a/frontend/components/integrations/ProjectIntegrationSection.tsx b/frontend/components/integrations/ProjectIntegrationSection.tsx new file mode 100644 index 000000000..be8f9f8d7 --- /dev/null +++ b/frontend/components/integrations/ProjectIntegrationSection.tsx @@ -0,0 +1,30 @@ +import React from "react"; +import Integration from "./Integration"; +import guidGenerator from "~/utilities/randomId"; + +interface Props { + projectIntegrations: any +} + +const ProjectIntegrationSection = ({ + projectIntegrations +}: Props) => { + return ( + <> +
+

Current Project Integrations

+

+ Manage your integrations of Infisical with third-party services. +

+
+ {projectIntegrations.map((projectIntegration) => ( + + ))} + + ); +} + +export default ProjectIntegrationSection; \ No newline at end of file diff --git a/frontend/pages/integrations/[id].js b/frontend/pages/integrations/[id].js index a301deda7..a6b06321d 100644 --- a/frontend/pages/integrations/[id].js +++ b/frontend/pages/integrations/[id].js @@ -4,8 +4,9 @@ import Image from "next/image"; import { useRouter } from "next/router"; import NavHeader from "~/components/navigation/NavHeader"; import Integration from "~/components/integrations/Integration"; -import FrameworkIntegration from "~/components/integrations/FrameworkIntegration"; -import CloudIntegration from "~/components/integrations/CloudIntegration"; +import FrameworkIntegrationSection from "~/components/integrations/FrameworkIntegrationSection"; +import CloudIntegrationSection from "~/components/integrations/CloudIntegrationSection"; +import ProjectIntegrationSection from "~/components/integrations/ProjectIntegrationSection"; import guidGenerator from "~/utilities/randomId"; import { frameworks @@ -180,50 +181,21 @@ export default function Integrations() { handleBotActivate={handleBotActivate} handleIntegrationOption={handleIntegrationOption} /> + {projectIntegrations.length > 0 && ( - <> -
-

Current Project Integrations

-

- Manage your integrations of Infisical with third-party services. -

-
- {projectIntegrations.map((projectIntegration) => ( - - ))} - + )} -
0 ? 'mt-12' : 'mt-6'} text-xl max-w-5xl px-2`}> -

Cloud Integrations

-

- Click on an integration to begin syncing secrets to it. -

-
-
- {integrations.map((integration) => ( - - ))} -
-
-

Framework Integrations

-

- Click on a framework to get the setup instructions. -

-
-
- {frameworks.map((framework) => ( - - ))} -
+ +
) : ( From 10d57e9d88504688e17ea8fdc43e81ada422ba07 Mon Sep 17 00:00:00 2001 From: Tuan Dang Date: Mon, 12 Dec 2022 00:23:13 -0500 Subject: [PATCH 10/25] Modularize integrations into json files, continue refactoring integrations frontend --- .../src/controllers/integrationController.ts | 25 ----- backend/src/json/integrations.json | 50 ---------- backend/src/routes/integration.ts | 2 - .../integrations/CloudIntegration.tsx | 4 +- .../integrations/CloudIntegrationSection.tsx | 27 ++--- .../integrations/FrameworkIntegration.tsx | 11 +-- .../FrameworkIntegrationSection.tsx | 5 +- .../pages/api/integrations/GetIntegrations.ts | 18 ---- frontend/pages/heroku.js | 5 - frontend/pages/integrations/[id].js | 34 ++----- frontend/pages/vercel.js | 29 ++++++ frontend/public/data/frequentConstants.ts | 64 ------------ frontend/public/json/cloudIntegrations.json | 58 +++++++++++ .../public/json/frameworkIntegrations.json | 98 +++++++++++++++++++ 14 files changed, 217 insertions(+), 213 deletions(-) delete mode 100644 backend/src/json/integrations.json delete mode 100644 frontend/pages/api/integrations/GetIntegrations.ts create mode 100644 frontend/pages/vercel.js create mode 100644 frontend/public/json/cloudIntegrations.json create mode 100644 frontend/public/json/frameworkIntegrations.json diff --git a/backend/src/controllers/integrationController.ts b/backend/src/controllers/integrationController.ts index 665343bd4..bf218f457 100644 --- a/backend/src/controllers/integrationController.ts +++ b/backend/src/controllers/integrationController.ts @@ -22,31 +22,6 @@ interface PushSecret { type: 'shared' | 'personal'; } -/** - * Return list of all available integrations on Infisical - * @param req - * @param res - * @returns - */ -export const getIntegrations = async (req: Request, res: Response) => { - let integrations; - try { - integrations = JSON.parse( - readFileSync('./src/json/integrations.json').toString() - ); - } catch (err) { - Sentry.setUser(null); - Sentry.captureException(err); - return res.status(400).send({ - message: 'Failed to get integrations' - }); - } - - return res.status(200).send({ - integrations - }); -}; - /** * Change environment or name of integration with id [integrationId] * @param req diff --git a/backend/src/json/integrations.json b/backend/src/json/integrations.json deleted file mode 100644 index 16b09ebf4..000000000 --- a/backend/src/json/integrations.json +++ /dev/null @@ -1,50 +0,0 @@ -{ - "heroku": { - "name": "Heroku", - "type": "oauth2", - "clientId": "bc132901-935a-4590-b010-f1857efc380d", - "docsLink": "" - }, - "netlify": { - "name": "Netlify", - "type": "oauth2", - "clientId": "", - "docsLink": "" - }, - "digitalocean": { - "name": "Digital Ocean", - "type": "oauth2", - "clientId": "", - "docsLink": "" - }, - "gcp": { - "name": "Google Cloud Platform", - "type": "oauth2", - "clientId": "", - "docsLink": "" - }, - "aws": { - "name": "Amazon Web Services", - "type": "oauth2", - "clientId": "", - "docsLink": "" - }, - "azure": { - "name": "Microsoft Azure", - "type": "oauth2", - "clientId": "", - "docsLink": "" - }, - "travisci": { - "name": "Travis CI", - "type": "oauth2", - "clientId": "", - "docsLink": "" - }, - "circleci": { - "name": "Circle CI", - "type": "oauth2", - "clientId": "", - "docsLink": "" - } -} diff --git a/backend/src/routes/integration.ts b/backend/src/routes/integration.ts index 4d025b1e8..88eebaa5f 100644 --- a/backend/src/routes/integration.ts +++ b/backend/src/routes/integration.ts @@ -9,8 +9,6 @@ import { ADMIN, MEMBER, GRANTED } from '../variables'; import { body, param } from 'express-validator'; import { integrationController } from '../controllers'; -router.get('/integrations', requireAuth, integrationController.getIntegrations); - router.patch( '/:integrationId', requireAuth, diff --git a/frontend/components/integrations/CloudIntegration.tsx b/frontend/components/integrations/CloudIntegration.tsx index e59d58a50..4fb53d145 100644 --- a/frontend/components/integrations/CloudIntegration.tsx +++ b/frontend/components/integrations/CloudIntegration.tsx @@ -28,7 +28,7 @@ const CloudIntegration = ({ deleteIntegrationAuth, authorizations }: Props) => { - return ( + return authorizations ? (
)}
- ); + ) :
} export default CloudIntegration; \ No newline at end of file diff --git a/frontend/components/integrations/CloudIntegrationSection.tsx b/frontend/components/integrations/CloudIntegrationSection.tsx index 51b2b5d95..75391275f 100644 --- a/frontend/components/integrations/CloudIntegrationSection.tsx +++ b/frontend/components/integrations/CloudIntegrationSection.tsx @@ -28,21 +28,22 @@ const CloudIntegrationSection = ({ return ( <>
0 ? 'mt-12' : 'mt-6'} text-xl max-w-5xl px-2`}> -

Cloud Integrations

-

- Click on an integration to begin syncing secrets to it. -

+

Cloud Integrations

+

+ Click on an integration to begin syncing secrets to it. +

- {integrations.map((integration) => ( - - ))} + {integrations.map((integration) => ( + + ))}
); diff --git a/frontend/components/integrations/FrameworkIntegration.tsx b/frontend/components/integrations/FrameworkIntegration.tsx index 2f33c0023..432dbad51 100644 --- a/frontend/components/integrations/FrameworkIntegration.tsx +++ b/frontend/components/integrations/FrameworkIntegration.tsx @@ -3,8 +3,9 @@ import Image from "next/image"; interface Framework { name: string; - link: string; + slug: string; image: string; + docsLink: string; } const FrameworkIntegration = ({ @@ -13,13 +14,12 @@ const FrameworkIntegration = ({ framework: Framework; }) => { return ( -
-
1 ? "text-sm px-1" : "text-xl px-2"} text-center w-full max-w-xs`}> + ); } diff --git a/frontend/components/integrations/FrameworkIntegrationSection.tsx b/frontend/components/integrations/FrameworkIntegrationSection.tsx index 0884d9705..c83599dc1 100644 --- a/frontend/components/integrations/FrameworkIntegrationSection.tsx +++ b/frontend/components/integrations/FrameworkIntegrationSection.tsx @@ -22,7 +22,10 @@ const FrameworkIntegrationSection = ({ frameworks }: Props) => {
{frameworks.map((framework) => ( - + ))}
diff --git a/frontend/pages/api/integrations/GetIntegrations.ts b/frontend/pages/api/integrations/GetIntegrations.ts deleted file mode 100644 index c189010a4..000000000 --- a/frontend/pages/api/integrations/GetIntegrations.ts +++ /dev/null @@ -1,18 +0,0 @@ -import SecurityClient from '~/utilities/SecurityClient'; - -const getIntegrations = () => { - return SecurityClient.fetchCall('/api/v1/integration/integrations', { - method: 'GET', - headers: { - 'Content-Type': 'application/json' - } - }).then(async (res) => { - if (res && res.status == 200) { - return (await res.json()).integrations; - } else { - console.log('Failed to get project integrations'); - } - }); -}; - -export default getIntegrations; diff --git a/frontend/pages/heroku.js b/frontend/pages/heroku.js index 42e915ba4..298fee08f 100644 --- a/frontend/pages/heroku.js +++ b/frontend/pages/heroku.js @@ -5,7 +5,6 @@ const queryString = require("query-string"); import AuthorizeIntegration from "./api/integrations/authorizeIntegration"; export default function Heroku() { - console.log('HEROKU PAGE'); const router = useRouter(); const parsedUrl = queryString.parse(router.asPath.split("?")[1]); const code = parsedUrl.code; @@ -17,11 +16,7 @@ export default function Heroku() { // eslint-disable-next-line react-hooks/exhaustive-deps useEffect(async () => { try { - console.log('A'); - console.log(state); - console.log(localStorage.getItem('latestCSRFToken')); if (state == localStorage.getItem("latestCSRFToken")) { - console.log('B'); await AuthorizeIntegration({ workspaceId: localStorage.getItem("projectData.id"), code, diff --git a/frontend/pages/integrations/[id].js b/frontend/pages/integrations/[id].js index a6b06321d..6ffccac90 100644 --- a/frontend/pages/integrations/[id].js +++ b/frontend/pages/integrations/[id].js @@ -7,12 +7,9 @@ import Integration from "~/components/integrations/Integration"; import FrameworkIntegrationSection from "~/components/integrations/FrameworkIntegrationSection"; import CloudIntegrationSection from "~/components/integrations/CloudIntegrationSection"; import ProjectIntegrationSection from "~/components/integrations/ProjectIntegrationSection"; -import guidGenerator from "~/utilities/randomId"; -import { - frameworks -} from "../../public/data/frequentConstants"; +import frameworkIntegrations from "../../public/json/frameworkIntegrations.json"; +import cloudIntegrations from "../../public/json/cloudIntegrations.json"; import deleteIntegrationAuth from "../api/integrations/DeleteIntegrationAuth"; -import getIntegrations from "../api/integrations/GetIntegrations"; import getWorkspaceAuthorizations from "../api/integrations/getWorkspaceAuthorizations"; import getWorkspaceIntegrations from "../api/integrations/getWorkspaceIntegrations"; import getBot from "../api/bot/getBot"; @@ -26,7 +23,6 @@ const { const crypto = require("crypto"); export default function Integrations() { - const [integrations, setIntegrations] = useState([]); const [projectIntegrations, setProjectIntegrations] = useState([]); const [authorizations, setAuthorizations] = useState(); const [bot, setBot] = useState(null); @@ -54,14 +50,7 @@ export default function Integrations() { workspaceId: router.query.id }); setBot(bot.bot); - - // get cloud integration options - let integrationOptions = await getIntegrations(); - integrationOptions = Object - .keys(integrationOptions) - .map(integrationOption => integrationOptions[integrationOption]); - setIntegrations(integrationOptions); } catch (err) { console.log(err); } @@ -160,7 +149,7 @@ export default function Integrations() { } } - return integrations ? ( + return (
Dashboard @@ -181,7 +170,6 @@ export default function Integrations() { handleBotActivate={handleBotActivate} handleIntegrationOption={handleIntegrationOption} /> - {projectIntegrations.length > 0 && ( - +
- ) : ( -
-
- loading animation -
); } diff --git a/frontend/pages/vercel.js b/frontend/pages/vercel.js new file mode 100644 index 000000000..cf317ec0f --- /dev/null +++ b/frontend/pages/vercel.js @@ -0,0 +1,29 @@ +import React, { useEffect } from "react"; +import Head from "next/head"; +import { useRouter } from "next/router"; +const queryString = require("query-string"); +import AuthorizeIntegration from "./api/integrations/authorizeIntegration"; + +export default function Vercel() { + const router = useRouter(); + const parsedUrl = queryString.parse(router.asPath.split("?")[1]); + + /** + * Here we forward to the default workspace if a user opens this url + */ + // eslint-disable-next-line react-hooks/exhaustive-deps + useEffect(async () => { + console.log('parsedUrl, xxx', parsedUrl); + try { + + } catch (err) { + + } + + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return
; +} + +Vercel.requireAuth = true; diff --git a/frontend/public/data/frequentConstants.ts b/frontend/public/data/frequentConstants.ts index fbc0034d3..d44706660 100644 --- a/frontend/public/data/frequentConstants.ts +++ b/frontend/public/data/frequentConstants.ts @@ -12,71 +12,7 @@ const reverseEnvMapping = { test: "Testing", }; -const frameworks = [{ - "name": "Docker", - "image": "Docker", - "link": "https://infisical.com/docs/integrations/platforms/docker" - }, { - "name": "Docker Compose", - "image": "Docker Compose", - "link": "https://infisical.com/docs/integrations/platforms/docker-compose" - }, { - "name": "React", - "image": "React", - "link": "https://infisical.com/docs/integrations/frameworks/react" - }, { - "name": "Vue", - "image": "Vue", - "link": "https://infisical.com/docs/integrations/frameworks/vue" - }, { - "image": "Express", - "link": "https://infisical.com/docs/integrations/frameworks/express" - },{ - "image": "Next.js", - "link": "https://infisical.com/docs/integrations/frameworks/nextjs" - }, { - "name": "Django", - "image": "Django", - "link": "https://infisical.com/docs/integrations/frameworks/django" - }, { - "name": "NestJS", - "image": "NestJS", - "link": "https://infisical.com/docs/integrations/frameworks/nestjs" - }, { - "name": "Nuxt", - "image": "Nuxt", - "link": "https://infisical.com/docs/integrations/frameworks/nuxt" - }, { - "name": "Gatsby", - "image": "Gatsby", - "link": "https://infisical.com/docs/integrations/frameworks/gatsby" - }, { - "name": "Remix", - "image": "Remix", - "link": "https://infisical.com/docs/integrations/frameworks/remix" - }, { - "name": "Vite", - "image": "Vite", - "link": "https://infisical.com/docs/integrations/frameworks/vite" - }, { - "image": "Fiber", - "link": "https://infisical.com/docs/integrations/frameworks/fiber" - }, { - "name": "Flask", - "image": "Flask", - "link": "https://infisical.com/docs/integrations/frameworks/flask" - }, { - "name": "Laravel", - "image": "Laravel", - "link": "https://infisical.com/docs/integrations/frameworks/laravel" - }, { - "image": "Rails", - "link": "https://infisical.com/docs/integrations/frameworks/rails" - } -] - export { envMapping, - frameworks, reverseEnvMapping }; diff --git a/frontend/public/json/cloudIntegrations.json b/frontend/public/json/cloudIntegrations.json new file mode 100644 index 000000000..7d11b3b51 --- /dev/null +++ b/frontend/public/json/cloudIntegrations.json @@ -0,0 +1,58 @@ +[ + { + "name": "Heroku", + "slug": "heroku", + "image": "Heroku", + "type": "oauth2", + "clientId": "bc132901-935a-4590-b010-f1857efc380d" + }, + { + "name": "Netlify", + "slug": "netlify", + "image": "Netlify", + "type": "oauth2", + "clientId": "" + }, + { + "name": "Digital Ocean", + "slug": "digital-ocean", + "image": "Digital Ocean", + "type": "", + "clientId": "" + }, + { + "name": "Google Cloud Platform", + "slug": "gcp", + "image": "Google Cloud Platform", + "type": "", + "clientId": "" + }, + { + "name": "Amazon Web Services", + "slug": "aws", + "image": "Amazon Web Services", + "type": "", + "clientId": "" + }, + { + "name": "Microsoft Azure", + "slug": "azure", + "image": "Microsoft Azure", + "type": "", + "clientId": "" + }, + { + "name": "Travis CI", + "slug": "travisci", + "image": "Travis CI", + "type": "", + "clientId": "" + }, + { + "name": "Circle CI", + "slug": "circleci", + "image": "Circle CI", + "type": "", + "clientId": "" + } +] \ No newline at end of file diff --git a/frontend/public/json/frameworkIntegrations.json b/frontend/public/json/frameworkIntegrations.json new file mode 100644 index 000000000..fb8b30c9e --- /dev/null +++ b/frontend/public/json/frameworkIntegrations.json @@ -0,0 +1,98 @@ +[ + { + "name": "Docker", + "slug": "docker", + "image": "Docker", + "docsLink": "https://infisical.com/docs/integrations/platforms/docker" + }, + { + "name": "Docker Compose", + "slug": "docker-compose", + "image": "Docker Compose", + "docsLink": "https://infisical.com/docs/integrations/platforms/docker-compose" + }, + { + "name": "React", + "slug": "react", + "image": "React", + "docsLink": "https://infisical.com/docs/integrations/frameworks/react" + }, + { + "name": "Vue", + "slug": "vue", + "image": "Vue", + "docsLink": "https://infisical.com/docs/integrations/frameworks/vue" + }, + { + "name": "Express", + "slug": "express", + "image": "Express", + "docsLink": "https://infisical.com/docs/integrations/frameworks/express" + }, + { + "name": "Next.js", + "slug": "nextjs", + "image": "Next.js", + "docsLink": "https://infisical.com/docs/integrations/frameworks/nextjs" + }, + { + "name": "Django", + "slug": "django", + "image": "Django", + "docsLink": "https://infisical.com/docs/integrations/frameworks/django" + }, + { + "name": "NestJS", + "slug": "nestjs", + "image": "NestJS", + "docsLink": "https://infisical.com/docs/integrations/frameworks/nestjs" + }, + { + "name": "Nuxt", + "slug": "nuxt", + "image": "Nuxt", + "docsLink": "https://infisical.com/docs/integrations/frameworks/nuxt" + }, + { + "name": "Gatsby", + "slug": "gatsby", + "image": "Gatsby", + "docsLink": "https://infisical.com/docs/integrations/frameworks/gatsby" + }, + { + "name": "Remix", + "slug": "remix", + "image": "Remix", + "docsLink": "https://infisical.com/docs/integrations/frameworks/remix" + }, + { + "name": "Vite", + "slug": "vite", + "image": "Vite", + "docsLink": "https://infisical.com/docs/integrations/frameworks/vite" + }, + { + "name": "Fiber", + "slug": "fiber", + "image": "Fiber", + "docsLink": "https://infisical.com/docs/integrations/frameworks/fiber" + }, + { + "name": "Flask", + "slug": "flask", + "image": "Flask", + "docsLink": "https://infisical.com/docs/integrations/frameworks/flask" + }, + { + "name": "Laravel", + "slug": "laravel", + "image": "Laravel", + "docsLink": "https://infisical.com/docs/integrations/frameworks/laravel" + }, + { + "name": "Rails", + "slug": "rails", + "image": "Rails", + "docsLink": "https://infisical.com/docs/integrations/frameworks/rails" + } +] \ No newline at end of file From 397c15d61eb0ae76568030874d0286f1a2ffdd98 Mon Sep 17 00:00:00 2001 From: Tuan Dang Date: Mon, 12 Dec 2022 08:26:09 -0500 Subject: [PATCH 11/25] Continue integration frontend refactor --- frontend/pages/api/bot/getBot.ts | 2 +- frontend/pages/integrations/[id].js | 33 +++++++++++++---------------- 2 files changed, 16 insertions(+), 19 deletions(-) diff --git a/frontend/pages/api/bot/getBot.ts b/frontend/pages/api/bot/getBot.ts index 6621dbe2f..145b50891 100644 --- a/frontend/pages/api/bot/getBot.ts +++ b/frontend/pages/api/bot/getBot.ts @@ -21,7 +21,7 @@ const getBot = async ({ workspaceId }: Props) => { } ).then(async (res) => { if (res && res.status == 200) { - return await res.json(); + return (await res.json()).bot; } else { console.log("Failed to get bot for project"); } diff --git a/frontend/pages/integrations/[id].js b/frontend/pages/integrations/[id].js index 6ffccac90..0aa5df5e0 100644 --- a/frontend/pages/integrations/[id].js +++ b/frontend/pages/integrations/[id].js @@ -23,8 +23,8 @@ const { const crypto = require("crypto"); export default function Integrations() { - const [projectIntegrations, setProjectIntegrations] = useState([]); - const [authorizations, setAuthorizations] = useState(); + const [integrationAuths, setIntegrationAuths] = useState([]); + const [integrations, setIntegrations] = useState([]); const [bot, setBot] = useState(null); const [isActivateBotOpen, setIsActivateBotOpen] = useState(false); const [selectedIntegrationOption, setSelectedIntegrationOption] = useState(null); @@ -33,23 +33,20 @@ export default function Integrations() { useEffect(async () => { try { - // get integrations authorized for project - let projectAuthorizations = await getWorkspaceAuthorizations({ + // get project integration authorizations + setIntegrationAuths(await getWorkspaceAuthorizations({ workspaceId: router.query.id, - }); - setAuthorizations(projectAuthorizations); + })); - // get active/inactive (cloud) integrations for project - const projectIntegrations = await getWorkspaceIntegrations({ + // get project integrations + setIntegrations(await getWorkspaceIntegrations({ workspaceId: router.query.id, - }); - setProjectIntegrations(projectIntegrations); + })); - // get bot for project - const bot = await getBot({ + // get project bot + setBot(await getBot({ workspaceId: router.query.id - }); - setBot(bot.bot); + })); } catch (err) { console.log(err); @@ -170,18 +167,18 @@ export default function Integrations() { handleBotActivate={handleBotActivate} handleIntegrationOption={handleIntegrationOption} /> - {projectIntegrations.length > 0 && ( + {integrations.length > 0 && ( // shouldn't need to check for length )} Date: Mon, 12 Dec 2022 10:36:13 -0500 Subject: [PATCH 12/25] Continue trimming frontend integration page and renaming variables --- backend/src/helpers/integration.ts | 3 +- .../integrations/CloudIntegration.tsx | 59 +++++++++---------- .../integrations/CloudIntegrationSection.tsx | 25 ++++---- .../components/integrations/Integration.tsx | 29 ++++----- ...tionSection.tsx => IntegrationSection.tsx} | 22 ++++--- frontend/pages/integrations/[id].js | 50 ++++++++-------- 6 files changed, 94 insertions(+), 94 deletions(-) rename frontend/components/integrations/{ProjectIntegrationSection.tsx => IntegrationSection.tsx} (71%) diff --git a/backend/src/helpers/integration.ts b/backend/src/helpers/integration.ts index a9d62f92f..d0e775bfc 100644 --- a/backend/src/helpers/integration.ts +++ b/backend/src/helpers/integration.ts @@ -84,7 +84,6 @@ const handleOAuthExchangeHelper = async ({ }).save(); } catch (err) { - console.error('in', err); Sentry.setUser(null); Sentry.captureException(err); throw new Error('Failed to handle OAuth2 code-token exchange') @@ -184,7 +183,7 @@ const getIntegrationAuthAccessHelper = async ({ integrationAuthId }: { integrati try { const integrationAuth = await IntegrationAuth .findById(integrationAuthId) - .select('+accessCiphertext +accessIV +accessTag'); + .select('workspace integration +accessCiphertext +accessIV +accessTag +accessExpiresAt + refreshCiphertext'); if (!integrationAuth) throw new Error('Failed to find integration auth'); diff --git a/frontend/components/integrations/CloudIntegration.tsx b/frontend/components/integrations/CloudIntegration.tsx index 4fb53d145..bafb28b20 100644 --- a/frontend/components/integrations/CloudIntegration.tsx +++ b/frontend/components/integrations/CloudIntegration.tsx @@ -5,77 +5,76 @@ import { faCheck, faX, } from "@fortawesome/free-solid-svg-icons"; +import deleteIntegrationAuth from "../../pages/api/integrations/DeleteIntegrationAuth"; -interface Props { - integration: IntegrationOption; - setSelectedIntegrationOption: () => void; - integrationOptionPress: () => void; - deleteIntegrationAuth: () => void; - authorizations: any; -} - -interface IntegrationOption { +interface CloudIntegrationOption { name: string; type: string; clientId: string; docsLink: string; } +interface Props { + cloudIntegrationOption: CloudIntegrationOption; + setSelectedIntegrationOption: () => void; + integrationOptionPress: () => void; + integrationAuths: any; +} + const CloudIntegration = ({ - integration, + cloudIntegrationOption, setSelectedIntegrationOption, integrationOptionPress, - deleteIntegrationAuth, - authorizations + integrationAuths }: Props) => { - return authorizations ? ( + return integrationAuths ? (
{ - if (!["Heroku"].includes(integration.name)) return; - setSelectedIntegrationOption(integration); + if (!["Heroku"].includes(cloudIntegrationOption.name)) return; + setSelectedIntegrationOption(cloudIntegrationOption); integrationOptionPress({ - integrationOption: integration + integrationOption: cloudIntegrationOption }); }} - key={integration.name} + key={cloudIntegrationOption.name} > integration logo - {integration.name.split(" ").length > 2 ? ( + {cloudIntegrationOption.name.split(" ").length > 2 ? (
-
{integration.name.split(" ")[0]}
+
{cloudIntegrationOption.name.split(" ")[0]}
- {integration.name.split(" ")[1]}{" "} - {integration.name.split(" ")[2]} + {cloudIntegrationOption.name.split(" ")[1]}{" "} + {cloudIntegrationOption.name.split(" ")[2]}
) : (
- {integration.name} + {cloudIntegrationOption.name}
)} - {["Heroku"].includes(integration.name) && - authorizations + {["Heroku"].includes(cloudIntegrationOption.name) && + integrationAuths .map((authorization) => authorization.integration) - .includes(integration.name.toLowerCase()) && ( + .includes(cloudIntegrationOption.name.toLowerCase()) && (
{ deleteIntegrationAuth({ - integrationAuthId: authorizations + integrationAuthId: integrationAuths .filter( (authorization) => authorization.integration == - integration.name.toLowerCase() + cloudIntegrationOption.name.toLowerCase() ) .map((authorization) => authorization._id)[0], }); @@ -98,7 +97,7 @@ const CloudIntegration = ({
)} - {!["Heroku"].includes(integration.name) && ( + {!["Heroku"].includes(cloudIntegrationOption.name) && (
Coming Soon diff --git a/frontend/components/integrations/CloudIntegrationSection.tsx b/frontend/components/integrations/CloudIntegrationSection.tsx index 75391275f..87ff74df1 100644 --- a/frontend/components/integrations/CloudIntegrationSection.tsx +++ b/frontend/components/integrations/CloudIntegrationSection.tsx @@ -1,7 +1,7 @@ import React from "react"; import CloudIntegration from "./CloudIntegration"; -interface IntegrationOption { +interface CloudIntegrationOption { name: string; type: string; clientId: string; @@ -9,39 +9,34 @@ interface IntegrationOption { } interface Props { - projectIntegrations: any; - integrations: IntegrationOption[]; + cloudIntegrationOptions: CloudIntegrationOption[]; setSelectedIntegrationOption: () => void; integrationOptionPress: () => void; - deleteIntegrationAuth: () => void; - authorizations: any; + integrationAuths: any; } const CloudIntegrationSection = ({ - projectIntegrations, - integrations, + cloudIntegrationOptions, setSelectedIntegrationOption, integrationOptionPress, - deleteIntegrationAuth, - authorizations + integrationAuths }: Props) => { return ( <> -
0 ? 'mt-12' : 'mt-6'} text-xl max-w-5xl px-2`}> +

Cloud Integrations

Click on an integration to begin syncing secrets to it.

- {integrations.map((integration) => ( + {cloudIntegrationOptions.map((cloudIntegrationOption) => ( ))}
diff --git a/frontend/components/integrations/Integration.tsx b/frontend/components/integrations/Integration.tsx index 6fd31ef2d..fa203b9bd 100644 --- a/frontend/components/integrations/Integration.tsx +++ b/frontend/components/integrations/Integration.tsx @@ -15,7 +15,7 @@ import getIntegrationApps from "../../pages/api/integrations/GetIntegrationApps" import Button from "~/components/basic/buttons/Button"; import ListBox from "~/components/basic/Listbox"; -interface ProjectIntegration { +interface Integration { app?: string; environment: string; integration: string; @@ -24,28 +24,29 @@ interface ProjectIntegration { } const Integration = ({ - projectIntegration + integration }: { - projectIntegration: ProjectIntegration; + integration: Integration; }) => { const [integrationEnvironment, setIntegrationEnvironment] = useState( - reverseEnvMapping[projectIntegration.environment] + reverseEnvMapping[integration.environment] ); const [fileState, setFileState] = useState([]); const router = useRouter(); const [apps, setApps] = useState([]); const [integrationApp, setIntegrationApp] = useState( - projectIntegration.app ? projectIntegration.app : apps[0] + integration.app ? integration.app : apps[0] ); useEffect(async () => { const tempHerokuApps = await getIntegrationApps({ - integrationAuthId: projectIntegration.integrationAuth, + integrationAuthId: integration.integrationAuth, }); + const tempHerokuAppNames = tempHerokuApps.map((app) => app.name); setApps(tempHerokuAppNames); setIntegrationApp( - projectIntegration.app ? projectIntegration.app : tempHerokuAppNames[0] + integration.app ? integration.app : tempHerokuAppNames[0] ); }, []); @@ -59,7 +60,7 @@ const Integration = ({
- {projectIntegration.integration.charAt(0).toUpperCase() + - projectIntegration.integration.slice(1)} + {integration.integration.charAt(0).toUpperCase() + + integration.integration.slice(1)}
@@ -87,14 +88,14 @@ const Integration = ({ HEROKU APP
- {projectIntegration.isActive ? ( + {integration.isActive ? (
{ const result = await updateIntegration({ - integrationId: projectIntegration._id, + integrationId: integration._id, environment: envMapping[integrationEnvironment], app: integrationApp, isActive: true @@ -122,7 +123,7 @@ const Integration = ({
From 271c8106920c96ff35d20030b69960bcf4b59328 Mon Sep 17 00:00:00 2001 From: Tuan Dang Date: Mon, 12 Dec 2022 10:58:49 -0500 Subject: [PATCH 13/25] Remove awkward lag when integration is loading its apps --- .../integrations/CloudIntegration.tsx | 78 ++++++++++--------- .../components/integrations/Integration.tsx | 21 ++--- frontend/public/json/cloudIntegrations.json | 8 ++ 3 files changed, 63 insertions(+), 44 deletions(-) diff --git a/frontend/components/integrations/CloudIntegration.tsx b/frontend/components/integrations/CloudIntegration.tsx index bafb28b20..9fb7213df 100644 --- a/frontend/components/integrations/CloudIntegration.tsx +++ b/frontend/components/integrations/CloudIntegration.tsx @@ -8,17 +8,23 @@ import { import deleteIntegrationAuth from "../../pages/api/integrations/DeleteIntegrationAuth"; interface CloudIntegrationOption { + isAvailable: Boolean; name: string; type: string; clientId: string; docsLink: string; } +interface IntegrationAuth { + id: string; + integration: string; +} + interface Props { cloudIntegrationOption: CloudIntegrationOption; setSelectedIntegrationOption: () => void; integrationOptionPress: () => void; - integrationAuths: any; + integrationAuths: IntegrationAuth[]; } const CloudIntegration = ({ @@ -27,34 +33,36 @@ const CloudIntegration = ({ integrationOptionPress, integrationAuths }: Props) => { + console.log('cloudIntegrationOption', cloudIntegrationOption); + console.log('integrationAuths', integrationAuths); return integrationAuths ? (
{ - if (!["Heroku"].includes(cloudIntegrationOption.name)) return; - setSelectedIntegrationOption(cloudIntegrationOption); - integrationOptionPress({ - integrationOption: cloudIntegrationOption - }); - }} - key={cloudIntegrationOption.name} + className={`relative ${ + cloudIntegrationOption.isAvailable + ? "hover:bg-white/10 duration-200 cursor-pointer" + : "opacity-50" + } flex flex-row bg-white/5 h-32 rounded-md p-4 items-center`} + onClick={() => { + if (!cloudIntegrationOption.isAvailable) return; + setSelectedIntegrationOption(cloudIntegrationOption); + integrationOptionPress({ + integrationOption: cloudIntegrationOption + }); + }} + key={cloudIntegrationOption.name} > integration logo {cloudIntegrationOption.name.split(" ").length > 2 ? (
{cloudIntegrationOption.name.split(" ")[0]}
- {cloudIntegrationOption.name.split(" ")[1]}{" "} - {cloudIntegrationOption.name.split(" ")[2]} + {cloudIntegrationOption.name.split(" ")[1]}{" "} + {cloudIntegrationOption.name.split(" ")[2]}
) : ( @@ -62,25 +70,25 @@ const CloudIntegration = ({ {cloudIntegrationOption.name}
)} - {["Heroku"].includes(cloudIntegrationOption.name) && + {cloudIntegrationOption.isAvailable && integrationAuths .map((authorization) => authorization.integration) .includes(cloudIntegrationOption.name.toLowerCase()) && (
{ - deleteIntegrationAuth({ - integrationAuthId: integrationAuths - .filter( - (authorization) => - authorization.integration == - cloudIntegrationOption.name.toLowerCase() - ) - .map((authorization) => authorization._id)[0], - }); - router.reload(); - }} - className="cursor-pointer w-max bg-red py-0.5 px-2 rounded-b-md text-xs flex flex-row items-center opacity-0 group-hover:opacity-100 duration-200" + onClick={() => { + deleteIntegrationAuth({ + integrationAuthId: integrationAuths + .filter( + (authorization) => + authorization.integration == + cloudIntegrationOption.name.toLowerCase() + ) + .map((authorization) => authorization._id)[0], + }); + router.reload(); + }} + className="cursor-pointer w-max bg-red py-0.5 px-2 rounded-b-md text-xs flex flex-row items-center opacity-0 group-hover:opacity-100 duration-200" >
)} - {!["Heroku"].includes(cloudIntegrationOption.name) && ( + {!cloudIntegrationOption.isAvailable && (
Coming Soon diff --git a/frontend/components/integrations/Integration.tsx b/frontend/components/integrations/Integration.tsx index fa203b9bd..e81fdb684 100644 --- a/frontend/components/integrations/Integration.tsx +++ b/frontend/components/integrations/Integration.tsx @@ -15,6 +15,8 @@ import getIntegrationApps from "../../pages/api/integrations/GetIntegrationApps" import Button from "~/components/basic/buttons/Button"; import ListBox from "~/components/basic/Listbox"; +// TODO: optimize laggy dropdown for app options + interface Integration { app?: string; environment: string; @@ -34,23 +36,21 @@ const Integration = ({ const [fileState, setFileState] = useState([]); const router = useRouter(); const [apps, setApps] = useState([]); - const [integrationApp, setIntegrationApp] = useState( - integration.app ? integration.app : apps[0] - ); + const [integrationApp, setIntegrationApp] = useState(null); useEffect(async () => { - const tempHerokuApps = await getIntegrationApps({ + const tempApps = await getIntegrationApps({ integrationAuthId: integration.integrationAuth, }); - const tempHerokuAppNames = tempHerokuApps.map((app) => app.name); - setApps(tempHerokuAppNames); + const tempAppNames = tempApps.map((app) => app.name); + setApps(tempAppNames); setIntegrationApp( - integration.app ? integration.app : tempHerokuAppNames[0] + integration.app ? integration.app : tempAppNames[0] ); }, []); - return ( + return (integrationApp && apps.length > 0) ? (
@@ -63,6 +63,7 @@ const Integration = ({ !integration.isActive && [ "Development", "Staging", + "Testing", "Production", ] } @@ -135,7 +136,9 @@ const Integration = ({
- ); + ) : ( +
+ ) }; export default Integration; \ No newline at end of file diff --git a/frontend/public/json/cloudIntegrations.json b/frontend/public/json/cloudIntegrations.json index 7d11b3b51..48cecec34 100644 --- a/frontend/public/json/cloudIntegrations.json +++ b/frontend/public/json/cloudIntegrations.json @@ -3,6 +3,7 @@ "name": "Heroku", "slug": "heroku", "image": "Heroku", + "isAvailable": true, "type": "oauth2", "clientId": "bc132901-935a-4590-b010-f1857efc380d" }, @@ -10,6 +11,7 @@ "name": "Netlify", "slug": "netlify", "image": "Netlify", + "isAvailable": false, "type": "oauth2", "clientId": "" }, @@ -17,6 +19,7 @@ "name": "Digital Ocean", "slug": "digital-ocean", "image": "Digital Ocean", + "isAvailable": false, "type": "", "clientId": "" }, @@ -24,6 +27,7 @@ "name": "Google Cloud Platform", "slug": "gcp", "image": "Google Cloud Platform", + "isAvailable": false, "type": "", "clientId": "" }, @@ -31,6 +35,7 @@ "name": "Amazon Web Services", "slug": "aws", "image": "Amazon Web Services", + "isAvailable": false, "type": "", "clientId": "" }, @@ -38,6 +43,7 @@ "name": "Microsoft Azure", "slug": "azure", "image": "Microsoft Azure", + "isAvailable": false, "type": "", "clientId": "" }, @@ -45,6 +51,7 @@ "name": "Travis CI", "slug": "travisci", "image": "Travis CI", + "isAvailable": false, "type": "", "clientId": "" }, @@ -52,6 +59,7 @@ "name": "Circle CI", "slug": "circleci", "image": "Circle CI", + "isAvailable": false, "type": "", "clientId": "" } From 3e623922b4b53e046f397c508030c9c9481873f5 Mon Sep 17 00:00:00 2001 From: Tuan Dang Date: Tue, 13 Dec 2022 13:59:21 -0500 Subject: [PATCH 14/25] Preliminary Vercel integration --- backend/src/config/index.ts | 6 +- .../controllers/integrationAuthController.ts | 1 - .../src/controllers/integrationController.ts | 11 +- backend/src/helpers/integration.ts | 60 ++++-- backend/src/integrations/apps.ts | 47 ++++- backend/src/integrations/exchange.ts | 81 +++++++- backend/src/integrations/sync.ts | 195 +++++++++++++++++- backend/src/models/integration.ts | 15 +- backend/src/models/integrationAuth.ts | 16 +- backend/src/variables/index.ts | 10 +- backend/src/variables/integration.ts | 16 +- .../integrations/CloudIntegration.tsx | 2 - .../components/integrations/Integration.tsx | 112 +++++----- .../integrations/IntegrationSection.tsx | 2 +- .../api/integrations/updateIntegration.js | 9 +- frontend/pages/heroku.js | 8 +- frontend/pages/integrations/[id].js | 11 +- frontend/pages/vercel.js | 29 ++- frontend/public/json/cloudIntegrations.json | 8 +- 19 files changed, 521 insertions(+), 118 deletions(-) diff --git a/backend/src/config/index.ts b/backend/src/config/index.ts index 5575ceb44..83d2bacdc 100644 --- a/backend/src/config/index.ts +++ b/backend/src/config/index.ts @@ -11,7 +11,8 @@ const JWT_SIGNUP_SECRET = process.env.JWT_SIGNUP_SECRET!; const MONGO_URL = process.env.MONGO_URL!; const NODE_ENV = process.env.NODE_ENV! || 'production'; const OAUTH_CLIENT_SECRET_HEROKU = process.env.OAUTH_CLIENT_SECRET_HEROKU!; -const OAUTH_TOKEN_URL_HEROKU = process.env.OAUTH_TOKEN_URL_HEROKU!; +const CLIENT_SECRET_VERCEL = process.env.CLIENT_SECRET_VERCEL!; +const CLIENT_ID_VERCEL = process.env.CLIENT_ID_VERCEL!; const POSTHOG_HOST = process.env.POSTHOG_HOST! || 'https://app.posthog.com'; const POSTHOG_PROJECT_API_KEY = process.env.POSTHOG_PROJECT_API_KEY! || @@ -47,7 +48,8 @@ export { MONGO_URL, NODE_ENV, OAUTH_CLIENT_SECRET_HEROKU, - OAUTH_TOKEN_URL_HEROKU, + CLIENT_SECRET_VERCEL, + CLIENT_ID_VERCEL, POSTHOG_HOST, POSTHOG_PROJECT_API_KEY, PRIVATE_KEY, diff --git a/backend/src/controllers/integrationAuthController.ts b/backend/src/controllers/integrationAuthController.ts index 829abc7c2..f38516e30 100644 --- a/backend/src/controllers/integrationAuthController.ts +++ b/backend/src/controllers/integrationAuthController.ts @@ -4,7 +4,6 @@ import axios from 'axios'; import { readFileSync } from 'fs'; import { IntegrationAuth, Integration } from '../models'; import { INTEGRATION_SET, ENV_DEV } from '../variables'; -import { OAUTH_CLIENT_SECRET_HEROKU, OAUTH_TOKEN_URL_HEROKU } from '../config'; import { IntegrationService } from '../services'; import { getApps } from '../integrations'; diff --git a/backend/src/controllers/integrationController.ts b/backend/src/controllers/integrationController.ts index bf218f457..87ee77dbe 100644 --- a/backend/src/controllers/integrationController.ts +++ b/backend/src/controllers/integrationController.ts @@ -31,17 +31,21 @@ interface PushSecret { export const updateIntegration = async (req: Request, res: Response) => { let integration; + // TODO: add integration-specific validation to ensure that each + // integration has the correct fields populated in [Integration] + try { - const { app, environment, isActive } = req.body; + const { app, environment, isActive, target } = req.body; integration = await Integration.findOneAndUpdate( { _id: req.integration._id }, { - app, environment, - isActive + isActive, + app, + target }, { new: true @@ -49,7 +53,6 @@ export const updateIntegration = async (req: Request, res: Response) => { ); if (integration) { - // trigger event - push secrets EventService.handleEvent({ event: eventPushSecrets({ diff --git a/backend/src/helpers/integration.ts b/backend/src/helpers/integration.ts index d0e775bfc..0c4bd4949 100644 --- a/backend/src/helpers/integration.ts +++ b/backend/src/helpers/integration.ts @@ -10,9 +10,16 @@ import { exchangeCode, exchangeRefresh, syncSecrets } from '../integrations'; import { BotService, IntegrationService } from '../services'; import { ENV_DEV, - EVENT_PUSH_SECRETS + EVENT_PUSH_SECRETS, + INTEGRATION_VERCEL } from '../variables'; +interface Update { + workspace: string; + integration: string; + teamId?: string; +} + /** * Perform OAuth2 code-token exchange for workspace with id [workspaceId] and integration * named [integration] @@ -49,29 +56,45 @@ const handleOAuthExchangeHelper = async ({ code }); + // TODO: continue ironing out Vercel integration + + let update: Update = { + workspace: workspaceId, + integration + } + + switch (integration) { + case INTEGRATION_VERCEL: + update.teamId = res.teamId; + break; + } + integrationAuth = await IntegrationAuth.findOneAndUpdate({ workspace: workspaceId, integration - }, { - workspace: workspaceId, - integration - }, { + }, update, { new: true, upsert: true }); - // set integration auth refresh token - await setIntegrationAuthRefreshHelper({ - integrationAuthId: integrationAuth._id.toString(), - refreshToken: res.refreshToken - }); + if (res.refreshToken) { + // case: refresh token returned from exchange + // set integration auth refresh token + await setIntegrationAuthRefreshHelper({ + integrationAuthId: integrationAuth._id.toString(), + refreshToken: res.refreshToken + }); + } - // set integration auth access token - await setIntegrationAuthAccessHelper({ - integrationAuthId: integrationAuth._id.toString(), - accessToken: res.accessToken, - accessExpiresAt: res.accessExpiresAt - }); + if (res.accessToken) { + // case: access token returned from exchange + // set integration auth access token + await setIntegrationAuthAccessHelper({ + integrationAuthId: integrationAuth._id.toString(), + accessToken: res.accessToken, + accessExpiresAt: res.accessExpiresAt + }); + } // initialize new integration after exchange await new Integration({ @@ -82,7 +105,6 @@ const handleOAuthExchangeHelper = async ({ integration, integrationAuth: integrationAuth._id }).save(); - } catch (err) { Sentry.setUser(null); Sentry.captureException(err); @@ -104,7 +126,7 @@ const syncIntegrationsHelper = async ({ try { integrations = await Integration.find({ workspace: workspaceId, - isActive: true, // TODO: filter so Integrations are ones with non-null apps + isActive: true, app: { $ne: null } }).populate<{integrationAuth: IIntegrationAuth}>('integrationAuth', 'accessToken'); @@ -126,11 +148,13 @@ const syncIntegrationsHelper = async ({ await syncSecrets({ integration: integration.integration, app: integration.app, + target: integration.target, secrets, accessToken }); } } catch (err) { + console.log('syncIntegrationsHelper error', err); Sentry.setUser(null); Sentry.captureException(err); throw new Error('Failed to sync secrets to integrations'); diff --git a/backend/src/integrations/apps.ts b/backend/src/integrations/apps.ts index c40d9f5ee..f25521481 100644 --- a/backend/src/integrations/apps.ts +++ b/backend/src/integrations/apps.ts @@ -2,7 +2,9 @@ import axios from 'axios'; import * as Sentry from '@sentry/node'; import { INTEGRATION_HEROKU, - INTEGRATION_HEROKU_APPS_URL + INTEGRATION_VERCEL, + INTEGRATION_HEROKU_API_URL, + INTEGRATION_VERCEL_API_URL } from '../variables'; /** @@ -28,6 +30,11 @@ const getApps = async ({ accessToken }); break; + case INTEGRATION_VERCEL: + apps = await getAppsVercel({ + accessToken + }); + break; } } catch (err) { @@ -53,14 +60,14 @@ const getAppsHeroku = async ({ }) => { let apps; try { - const res = await axios.get(INTEGRATION_HEROKU_APPS_URL, { + const res = (await axios.get(`${INTEGRATION_HEROKU_API_URL}/apps`, { headers: { Accept: 'application/vnd.heroku+json; version=3', Authorization: `Bearer ${accessToken}` } - }); + })).data; - apps = res.data.map((a: any) => ({ + apps = res.map((a: any) => ({ name: a.name })); } catch (err) { @@ -72,6 +79,38 @@ const getAppsHeroku = async ({ return apps; } +/** + * Return list of names of apps for Vercel 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 getAppsVercel = async ({ + accessToken +}: { + accessToken: string; +}) => { + let apps; + try { + const res = (await axios.get(`${INTEGRATION_VERCEL_API_URL}/v9/projects`, { + headers: { + Authorization: `Bearer ${accessToken}` + } + })).data; + + apps = res.projects.map((a: any) => ({ + name: a.name + })); + } catch (err) { + Sentry.setUser(null); + Sentry.captureException(err); + throw new Error('Failed to get Vercel integration apps'); + } + + return apps; +} + export { getApps } \ No newline at end of file diff --git a/backend/src/integrations/exchange.ts b/backend/src/integrations/exchange.ts index 1452c4e2c..9ce670144 100644 --- a/backend/src/integrations/exchange.ts +++ b/backend/src/integrations/exchange.ts @@ -2,13 +2,35 @@ import axios from 'axios'; import * as Sentry from '@sentry/node'; import { INTEGRATION_HEROKU, + INTEGRATION_VERCEL, INTEGRATION_HEROKU_TOKEN_URL, + INTEGRATION_VERCEL_TOKEN_URL, ACTION_PUSH_TO_HEROKU } from '../variables'; import { - OAUTH_CLIENT_SECRET_HEROKU + SITE_URL, + OAUTH_CLIENT_SECRET_HEROKU, + CLIENT_ID_VERCEL, + CLIENT_SECRET_VERCEL } from '../config'; +interface ExchangeCodeHerokuResponse { + token_type: string; + access_token: string; + expires_in: number; + refresh_token: string; + user_id: string; + session_nonce?: string; +} + +interface ExchangeCodeVercelResponse { + token_type: string; + access_token: string; + installation_id: string; + user_id: string; + team_id?: string; +} + /** * Return [accessToken], [accessExpiresAt], and [refreshToken] for OAuth2 * code-token exchange for integration named [integration] @@ -37,6 +59,10 @@ const exchangeCode = async ({ code }); break; + case INTEGRATION_VERCEL: + obj = await exchangeCodeVercel({ + code + }); } } catch (err) { Sentry.setUser(null); @@ -62,20 +88,20 @@ const exchangeCodeHeroku = async ({ }: { code: string; }) => { - let res: any; + let res: ExchangeCodeHerokuResponse; let accessExpiresAt = new Date(); try { - res = await axios.post( + res = (await axios.post( INTEGRATION_HEROKU_TOKEN_URL, new URLSearchParams({ grant_type: 'authorization_code', code: code, client_secret: OAUTH_CLIENT_SECRET_HEROKU } as any) - ); + )).data; accessExpiresAt.setSeconds( - accessExpiresAt.getSeconds() + res.data.expires_in + accessExpiresAt.getSeconds() + res.expires_in ); } catch (err) { Sentry.setUser(null); @@ -84,12 +110,53 @@ const exchangeCodeHeroku = async ({ } return ({ - accessToken: res.data.access_token, - refreshToken: res.data.refresh_token, + accessToken: res.access_token, + refreshToken: res.refresh_token, accessExpiresAt }); } +/** + * Return [accessToken], [accessExpiresAt], and [refreshToken] for Vercel + * 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 Heroku API + * @returns {String} obj2.refreshToken - refresh token for Heroku API + * @returns {Date} obj2.accessExpiresAt - date of expiration for access token + */ +const exchangeCodeVercel = async ({ + code +}: { + code: string; +}) => { + let res: ExchangeCodeVercelResponse; + try { + res = (await axios.post( + INTEGRATION_VERCEL_TOKEN_URL, + new URLSearchParams({ + code: code, + client_id: CLIENT_ID_VERCEL, + client_secret: CLIENT_SECRET_VERCEL, + redirect_uri: `${SITE_URL}/vercel` + } as any) + )).data; + + } catch (err) { + Sentry.setUser(null); + Sentry.captureException(err); + throw new Error('Failed OAuth2 code-token exchange with Vercel'); + } + + return ({ + accessToken: res.access_token, + refreshToken: null, + accessExpiresAt: null, + teamId: res.team_id + }); +} + export { exchangeCode } \ No newline at end of file diff --git a/backend/src/integrations/sync.ts b/backend/src/integrations/sync.ts index 15a61ad66..930721d72 100644 --- a/backend/src/integrations/sync.ts +++ b/backend/src/integrations/sync.ts @@ -1,23 +1,34 @@ import axios from 'axios'; import * as Sentry from '@sentry/node'; -import { INTEGRATION_HEROKU } from '../variables'; +import { + INTEGRATION_HEROKU, + INTEGRATION_VERCEL, + INTEGRATION_HEROKU_API_URL, + INTEGRATION_VERCEL_API_URL +} from '../variables'; + +// TODO: need a helper function in the future to handle integration +// envar priorities (i.e. prioritize secrets within integration or those on Infisical) /** * Sync/push [secrets] to [app] in integration named [integration] * @param {Object} obj * @param {Object} obj.integration - name of integration * @param {Object} obj.app - app in integration + * @param {Object} obj.target - (optional) target (environment) in integration * @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 integration */ const syncSecrets = async ({ integration, app, + target, secrets, - accessToken + accessToken, }: { integration: string; app: string; + target: string; secrets: any; accessToken: string; }) => { @@ -30,6 +41,13 @@ const syncSecrets = async ({ accessToken }); break; + case INTEGRATION_VERCEL: + await syncSecretsVercel({ + app, + target, + secrets, + accessToken + }); } } catch (err) { Sentry.setUser(null); @@ -54,13 +72,29 @@ const syncSecretsHeroku = async ({ accessToken: string; }) => { try { + const herokuSecrets = (await axios.get( + `${INTEGRATION_HEROKU_API_URL}/apps/${app}/config-vars`, + { + headers: { + Accept: 'application/vnd.heroku+json; version=3', + Authorization: `Bearer ${accessToken}` + } + } + )).data; + + Object.keys(herokuSecrets).forEach(key => { + if (!(key in secrets)) { + secrets[key] = null; + } + }); + await axios.patch( - `https://api.heroku.com/apps/${app}/config-vars`, + `${INTEGRATION_HEROKU_API_URL}/apps/${app}/config-vars`, secrets, { headers: { Accept: 'application/vnd.heroku+json; version=3', - Authorization: 'Bearer ' + accessToken + Authorization: `Bearer ${accessToken}` } } ); @@ -71,6 +105,159 @@ const syncSecretsHeroku = async ({ } } +/** + * Sync/push [secrets] to Heroku [app] + * @param {Object} obj + * @param {String} obj.app - app in integration + * @param {String} obj.target - (optional) target (environment) in integration + * @param {Object} obj.secrets - secrets to push to integration (object where keys are secret keys and values are secret values) + */ +const syncSecretsVercel = async ({ + app, + target, + secrets, + accessToken +}: { + app: string; + target: string; + secrets: any; + accessToken: string; +}) => { + + interface VercelSecret { + id?: string; + type: string; + key: string; + value: string; + target: string[]; + } + + try { + // Get all (decrypted) secrets back from Vercel in + // decrypted format + const params = new URLSearchParams({ + decrypt: "true" + }); + + const res = (await Promise.all((await axios.get( + `${INTEGRATION_VERCEL_API_URL}/v9/projects/${app}/env`, + { + params, + headers: { + Authorization: `Bearer ${accessToken}` + } + } + )) + .data + .envs + .filter((secret: VercelSecret) => secret.target.includes(target)) + .map(async (secret: VercelSecret) => (await axios.get( + `${INTEGRATION_VERCEL_API_URL}/v9/projects/${app}/env/${secret.id}`, + { + headers: { + Authorization: `Bearer ${accessToken}` + } + + } + )).data) + )).reduce((obj: any, secret: any) => ({ + ...obj, + [secret.key]: secret + }), {}); + + let updateSecrets: VercelSecret[] = []; + let deleteSecrets: VercelSecret[] = []; + let newSecrets: VercelSecret[] = []; + + // Identify secrets to create + Object.keys(secrets).map((key) => { + if (!(key in res)) { + newSecrets.push({ + key: key, + value: secrets[key], + type: 'encrypted', + target: [target] + }); + } + }); + + // Identify secrets to update and delete + Object.keys(res).map((key) => { + if (key in secrets) { + if (res[key].value !== secrets[key]) { + // case: secret value has changed + updateSecrets.push({ + id: res[key].id, + key: key, + value: secrets[key], + type: 'encrypted', + target: [target] + }); + } + } else { + // case: secret has been deleted + deleteSecrets.push({ + id: res[key].id, + key: key, + value: res[key].value, + type: 'encrypted', + target: [target], + }); + } + }); + + // Sync/push new secrets + if (newSecrets.length > 0) { + await axios.post( + `${INTEGRATION_VERCEL_API_URL}/v10/projects/${app}/env`, + newSecrets, + { + headers: { + Authorization: `Bearer ${accessToken}` + } + } + ); + } + + // Sync/push updated secrets + if (updateSecrets.length > 0) { + updateSecrets.forEach(async (secret: VercelSecret) => { + const { + id, + ...updatedSecret + } = secret; + await axios.patch( + `${INTEGRATION_VERCEL_API_URL}/v9/projects/${app}/env/${secret.id}`, + updatedSecret, + { + headers: { + Authorization: `Bearer ${accessToken}` + } + } + ); + }); + } + + // Delete secrets + if (deleteSecrets.length > 0) { + deleteSecrets.forEach(async (secret: VercelSecret) => { + await axios.delete( + `${INTEGRATION_VERCEL_API_URL}/v9/projects/${app}/env/${secret.id}`, + { + headers: { + Authorization: `Bearer ${accessToken}` + } + } + ); + }); + } + } catch (err) { + Sentry.setUser(null); + Sentry.captureException(err); + throw new Error('Failed to sync secrets to Vercel'); + } +} + export { syncSecrets } \ No newline at end of file diff --git a/backend/src/models/integration.ts b/backend/src/models/integration.ts index 90e3f3019..eb82b95ca 100644 --- a/backend/src/models/integration.ts +++ b/backend/src/models/integration.ts @@ -5,6 +5,7 @@ import { ENV_STAGING, ENV_PROD, INTEGRATION_HEROKU, + INTEGRATION_VERCEL, INTEGRATION_NETLIFY } from '../variables'; @@ -14,6 +15,7 @@ export interface IIntegration { environment: 'dev' | 'test' | 'staging' | 'prod'; isActive: boolean; app: string; + target: string; integration: 'heroku' | 'netlify'; integrationAuth: Types.ObjectId; } @@ -34,14 +36,21 @@ const integrationSchema = new Schema( type: Boolean, required: true }, - app: { - // name of app in provider + app: { // name of app in provider + type: String, + default: null + }, + target: { // vercel-specific target (environment) type: String, default: null }, integration: { type: String, - enum: [INTEGRATION_HEROKU, INTEGRATION_NETLIFY], + enum: [ + INTEGRATION_HEROKU, + INTEGRATION_VERCEL, + INTEGRATION_NETLIFY + ], required: true }, integrationAuth: { diff --git a/backend/src/models/integrationAuth.ts b/backend/src/models/integrationAuth.ts index 0e9542a20..b03a73e52 100644 --- a/backend/src/models/integrationAuth.ts +++ b/backend/src/models/integrationAuth.ts @@ -1,10 +1,15 @@ import { Schema, model, Types } from 'mongoose'; -import { INTEGRATION_HEROKU, INTEGRATION_NETLIFY } from '../variables'; +import { + INTEGRATION_HEROKU, + INTEGRATION_VERCEL, + INTEGRATION_NETLIFY +} from '../variables'; export interface IIntegrationAuth { _id: Types.ObjectId; workspace: Types.ObjectId; integration: 'heroku' | 'netlify'; + teamId: string; refreshCiphertext?: string; refreshIV?: string; refreshTag?: string; @@ -22,9 +27,16 @@ const integrationAuthSchema = new Schema( }, integration: { type: String, - enum: [INTEGRATION_HEROKU, INTEGRATION_NETLIFY], + enum: [ + INTEGRATION_HEROKU, + INTEGRATION_VERCEL, + INTEGRATION_NETLIFY + ], required: true }, + teamId: { // vercel-specific integration param set at OAuth2 code-token exchange + type: String + }, refreshCiphertext: { type: String, select: false diff --git a/backend/src/variables/index.ts b/backend/src/variables/index.ts index 9f8bcbd9c..78773e665 100644 --- a/backend/src/variables/index.ts +++ b/backend/src/variables/index.ts @@ -7,11 +7,14 @@ import { } from './environment'; import { INTEGRATION_HEROKU, + INTEGRATION_VERCEL, INTEGRATION_NETLIFY, INTEGRATION_SET, INTEGRATION_OAUTH2, INTEGRATION_HEROKU_TOKEN_URL, - INTEGRATION_HEROKU_APPS_URL + INTEGRATION_VERCEL_TOKEN_URL, + INTEGRATION_HEROKU_API_URL, + INTEGRATION_VERCEL_API_URL } from './integration'; import { OWNER, @@ -56,11 +59,14 @@ export { ENV_PROD, ENV_SET, INTEGRATION_HEROKU, + INTEGRATION_VERCEL, INTEGRATION_NETLIFY, INTEGRATION_SET, INTEGRATION_OAUTH2, INTEGRATION_HEROKU_TOKEN_URL, - INTEGRATION_HEROKU_APPS_URL, + INTEGRATION_VERCEL_TOKEN_URL, + INTEGRATION_HEROKU_API_URL, + INTEGRATION_VERCEL_API_URL, EVENT_PUSH_SECRETS, EVENT_PULL_SECRETS, ACTION_PUSH_TO_HEROKU diff --git a/backend/src/variables/integration.ts b/backend/src/variables/integration.ts index 6e4fe45b5..188bead38 100644 --- a/backend/src/variables/integration.ts +++ b/backend/src/variables/integration.ts @@ -1,22 +1,32 @@ // integrations const INTEGRATION_HEROKU = 'heroku'; +const INTEGRATION_VERCEL = 'vercel'; const INTEGRATION_NETLIFY = 'netlify'; -const INTEGRATION_SET = new Set([INTEGRATION_HEROKU, INTEGRATION_NETLIFY]); +const INTEGRATION_SET = new Set([ + INTEGRATION_HEROKU, + INTEGRATION_VERCEL, + INTEGRATION_NETLIFY +]); // integration types const INTEGRATION_OAUTH2 = 'oauth2'; // integration oauth endpoints const INTEGRATION_HEROKU_TOKEN_URL = 'https://id.heroku.com/oauth/token'; +const INTEGRATION_VERCEL_TOKEN_URL = 'https://api.vercel.com/v2/oauth/access_token'; // integration apps endpoints -const INTEGRATION_HEROKU_APPS_URL = 'https://api.heroku.com/apps'; +const INTEGRATION_HEROKU_API_URL = 'https://api.heroku.com'; +const INTEGRATION_VERCEL_API_URL = 'https://api.vercel.com'; export { INTEGRATION_HEROKU, + INTEGRATION_VERCEL, INTEGRATION_NETLIFY, INTEGRATION_SET, INTEGRATION_OAUTH2, INTEGRATION_HEROKU_TOKEN_URL, - INTEGRATION_HEROKU_APPS_URL + INTEGRATION_VERCEL_TOKEN_URL, + INTEGRATION_HEROKU_API_URL, + INTEGRATION_VERCEL_API_URL, } \ No newline at end of file diff --git a/frontend/components/integrations/CloudIntegration.tsx b/frontend/components/integrations/CloudIntegration.tsx index 9fb7213df..f3f1ad153 100644 --- a/frontend/components/integrations/CloudIntegration.tsx +++ b/frontend/components/integrations/CloudIntegration.tsx @@ -33,8 +33,6 @@ const CloudIntegration = ({ integrationOptionPress, integrationAuths }: Props) => { - console.log('cloudIntegrationOption', cloudIntegrationOption); - console.log('integrationAuths', integrationAuths); return integrationAuths ? (
{ const tempApps = await getIntegrationApps({ @@ -48,54 +50,70 @@ const Integration = ({ setIntegrationApp( integration.app ? integration.app : tempAppNames[0] ); + setIntegrationTarget("Development"); }, []); - - return (integrationApp && apps.length > 0) ? ( -
-
-
-
-
- ENVIRONMENT -
- -
+ + if (!integrationApp || apps.length === 0) return
+ + return ( +
+
+
+

ENVIRONMENT

+ +
+
-
-
- INTEGRATION -
-
- {integration.integration.charAt(0).toUpperCase() + - integration.integration.slice(1)} -
-
-
-
- HEROKU APP -
- + /> +
+
+

+ INTEGRATION +

+
+ {integration.integration.charAt(0).toUpperCase() + + integration.integration.slice(1)}
-
+
+
+ APP +
+ +
+ {integration.integration === "vercel" && ( +
+
+ ENVIRONMENT +
+ +
+ )} +
+
{integration.isActive ? (
-
- ) : ( -
- ) + ); }; export default Integration; \ No newline at end of file diff --git a/frontend/components/integrations/IntegrationSection.tsx b/frontend/components/integrations/IntegrationSection.tsx index 1c521f807..cfa43b4ef 100644 --- a/frontend/components/integrations/IntegrationSection.tsx +++ b/frontend/components/integrations/IntegrationSection.tsx @@ -16,7 +16,7 @@ const ProjectIntegrationSection = ({ return integrations.length > 0 ? (
-

Current Project Integrations

+

Current Integrations

Manage your integrations of Infisical with third-party services.

diff --git a/frontend/pages/api/integrations/updateIntegration.js b/frontend/pages/api/integrations/updateIntegration.js index db833caf1..cb5288f5c 100644 --- a/frontend/pages/api/integrations/updateIntegration.js +++ b/frontend/pages/api/integrations/updateIntegration.js @@ -9,13 +9,15 @@ import SecurityClient from "~/utilities/SecurityClient"; * @param {String} obj.app - name of app * @param {String} obj.environment - project environment to push secrets from * @param {Boolean} obj.isActive - active state + * @param {String} obj.target - (optional) target (environment) * @returns */ const updateIntegration = ({ integrationId, app, - environment, - isActive + environment, + isActive, + target }) => { return SecurityClient.fetchCall( "/api/v1/integration/" + integrationId, @@ -27,7 +29,8 @@ const updateIntegration = ({ body: JSON.stringify({ app, environment, - isActive + isActive, + target }), } ).then(async (res) => { diff --git a/frontend/pages/heroku.js b/frontend/pages/heroku.js index 298fee08f..088c96500 100644 --- a/frontend/pages/heroku.js +++ b/frontend/pages/heroku.js @@ -16,17 +16,17 @@ export default function Heroku() { // eslint-disable-next-line react-hooks/exhaustive-deps useEffect(async () => { try { - if (state == localStorage.getItem("latestCSRFToken")) { + if (state === localStorage.getItem('latestCSRFToken')) { + localStorage.removeItem('latestCSRFToken'); await AuthorizeIntegration({ - workspaceId: localStorage.getItem("projectData.id"), + workspaceId: localStorage.getItem('projectData.id'), code, integration: "heroku", }); router.push("/integrations/" + localStorage.getItem("projectData.id")); } } catch (error) { - console.error(error); - console.log("Error - Not logged in yet"); + console.error('Heroku integration error: ', error); } // eslint-disable-next-line react-hooks/exhaustive-deps }, []); diff --git a/frontend/pages/integrations/[id].js b/frontend/pages/integrations/[id].js index 388b6a5de..955d50201 100644 --- a/frontend/pages/integrations/[id].js +++ b/frontend/pages/integrations/[id].js @@ -113,7 +113,7 @@ export default function Integrations() { * @returns */ const handleIntegrationOption = async ({ integrationOption }) => { - // TODO: modularize + // TODO: modularize and handle switch by slug // generate CSRF token for OAuth2 code-token exchange integrations const csrfToken = crypto.randomBytes(16).toString("hex"); @@ -121,8 +121,13 @@ export default function Integrations() { switch (integrationOption.name) { case 'Heroku': - window.location = `https://id.heroku.com/oauth/authorize?client_id=7b1311a1-1cb2-4938-8adf-f37a399ec41b&response_type=code&scope=write-protected&state=${csrfToken}`; - return; + // console.log('Heroku integration ', integrationOption); + window.location = `https://id.heroku.com/oauth/authorize?client_id=${integrationOption.clientId}&response_type=code&scope=write-protected&state=${csrfToken}`; + break; + case 'Vercel': + console.log('Vercel integration ', integrationOption); + window.location = `https://vercel.com/integrations/infisical/new?state=${csrfToken}`; + break; } } diff --git a/frontend/pages/vercel.js b/frontend/pages/vercel.js index cf317ec0f..7005a2590 100644 --- a/frontend/pages/vercel.js +++ b/frontend/pages/vercel.js @@ -7,17 +7,40 @@ import AuthorizeIntegration from "./api/integrations/authorizeIntegration"; export default function Vercel() { const router = useRouter(); const parsedUrl = queryString.parse(router.asPath.split("?")[1]); + const code = parsedUrl.code; + const state = parsedUrl.state + + // modify comment here /** * Here we forward to the default workspace if a user opens this url */ // eslint-disable-next-line react-hooks/exhaustive-deps useEffect(async () => { - console.log('parsedUrl, xxx', parsedUrl); + console.log('parsedUrl for vercel ', parsedUrl); + if (state === localStorage.getItem('latestCSRFToken')) { + localStorage.removeItem('latestCSRFToken'); + + console.log('integ'); + console.log('code', code); + console.log('state', state); + + await AuthorizeIntegration({ + workspaceId: localStorage.getItem('projectData.id'), + code, + integration: "vercel" + }); + + router.push("/integrations/" + localStorage.getItem("projectData.id")); + } + // parsedUrl.code + // parsedUrl.configurationId + // parsedUrl.next + // parsedUrl.state try { - + } catch (err) { - + console.error('Vercel integration error: ', err); } // eslint-disable-next-line react-hooks/exhaustive-deps diff --git a/frontend/public/json/cloudIntegrations.json b/frontend/public/json/cloudIntegrations.json index 48cecec34..696be33de 100644 --- a/frontend/public/json/cloudIntegrations.json +++ b/frontend/public/json/cloudIntegrations.json @@ -5,14 +5,14 @@ "image": "Heroku", "isAvailable": true, "type": "oauth2", - "clientId": "bc132901-935a-4590-b010-f1857efc380d" + "clientId": "7b1311a1-1cb2-4938-8adf-f37a399ec41b" }, { - "name": "Netlify", + "name": "Vercel", "slug": "netlify", "image": "Netlify", - "isAvailable": false, - "type": "oauth2", + "isAvailable": true, + "type": "vercel", "clientId": "" }, { From d86c33567189892affea7935f2b643830918ef4a Mon Sep 17 00:00:00 2001 From: Tuan Dang Date: Tue, 13 Dec 2022 15:47:27 -0500 Subject: [PATCH 15/25] Begin Netlify integration --- backend/src/variables/index.ts | 6 ++- backend/src/variables/integration.ts | 4 ++ frontend/pages/integrations/[id].js | 4 +- frontend/pages/netlify.js | 43 +++++++++++++++++++++ frontend/pages/vercel.js | 12 +----- frontend/public/json/cloudIntegrations.json | 14 +++---- 6 files changed, 63 insertions(+), 20 deletions(-) create mode 100644 frontend/pages/netlify.js diff --git a/backend/src/variables/index.ts b/backend/src/variables/index.ts index 78773e665..362635806 100644 --- a/backend/src/variables/index.ts +++ b/backend/src/variables/index.ts @@ -13,8 +13,10 @@ import { INTEGRATION_OAUTH2, INTEGRATION_HEROKU_TOKEN_URL, INTEGRATION_VERCEL_TOKEN_URL, + INTEGRATION_NETLIFY_TOKEN_URL, INTEGRATION_HEROKU_API_URL, - INTEGRATION_VERCEL_API_URL + INTEGRATION_VERCEL_API_URL, + INTEGRATION_NETLIFY_API_URL } from './integration'; import { OWNER, @@ -65,8 +67,10 @@ export { INTEGRATION_OAUTH2, INTEGRATION_HEROKU_TOKEN_URL, INTEGRATION_VERCEL_TOKEN_URL, + INTEGRATION_NETLIFY_TOKEN_URL, INTEGRATION_HEROKU_API_URL, INTEGRATION_VERCEL_API_URL, + INTEGRATION_NETLIFY_API_URL, EVENT_PUSH_SECRETS, EVENT_PULL_SECRETS, ACTION_PUSH_TO_HEROKU diff --git a/backend/src/variables/integration.ts b/backend/src/variables/integration.ts index 188bead38..da45336fc 100644 --- a/backend/src/variables/integration.ts +++ b/backend/src/variables/integration.ts @@ -14,10 +14,12 @@ const INTEGRATION_OAUTH2 = 'oauth2'; // integration oauth endpoints const INTEGRATION_HEROKU_TOKEN_URL = 'https://id.heroku.com/oauth/token'; const INTEGRATION_VERCEL_TOKEN_URL = 'https://api.vercel.com/v2/oauth/access_token'; +const INTEGRATION_NETLIFY_TOKEN_URL = 'https://app.netlify.com/authorize'; // integration apps endpoints const INTEGRATION_HEROKU_API_URL = 'https://api.heroku.com'; const INTEGRATION_VERCEL_API_URL = 'https://api.vercel.com'; +const INTEGRATION_NETLIFY_API_URL = 'https://api.netlify.com'; export { INTEGRATION_HEROKU, @@ -27,6 +29,8 @@ export { INTEGRATION_OAUTH2, INTEGRATION_HEROKU_TOKEN_URL, INTEGRATION_VERCEL_TOKEN_URL, + INTEGRATION_NETLIFY_TOKEN_URL, INTEGRATION_HEROKU_API_URL, INTEGRATION_VERCEL_API_URL, + INTEGRATION_NETLIFY_API_URL, } \ No newline at end of file diff --git a/frontend/pages/integrations/[id].js b/frontend/pages/integrations/[id].js index 955d50201..864229d72 100644 --- a/frontend/pages/integrations/[id].js +++ b/frontend/pages/integrations/[id].js @@ -125,9 +125,11 @@ export default function Integrations() { window.location = `https://id.heroku.com/oauth/authorize?client_id=${integrationOption.clientId}&response_type=code&scope=write-protected&state=${csrfToken}`; break; case 'Vercel': - console.log('Vercel integration ', integrationOption); window.location = `https://vercel.com/integrations/infisical/new?state=${csrfToken}`; break; + case 'Netlify': + console.log('netlifyyy'); + break; } } diff --git a/frontend/pages/netlify.js b/frontend/pages/netlify.js new file mode 100644 index 000000000..06d6ab32b --- /dev/null +++ b/frontend/pages/netlify.js @@ -0,0 +1,43 @@ +import React, { useEffect } from "react"; +import Head from "next/head"; +import { useRouter } from "next/router"; +const queryString = require("query-string"); +import AuthorizeIntegration from "./api/integrations/authorizeIntegration"; + +export default function Netlify() { + const router = useRouter(); + const parsedUrl = queryString.parse(router.asPath.split("?")[1]); + // modify comment here + + /** + * Here we forward to the default workspace if a user opens this url + */ + // eslint-disable-next-line react-hooks/exhaustive-deps + useEffect(async () => { + if (state === localStorage.getItem('latestCSRFToken')) { + localStorage.removeItem('latestCSRFToken'); + + console.log('Netlify', parsedUrl); + + // await AuthorizeIntegration({ + // workspaceId: localStorage.getItem('projectData.id'), + // code, + // integration: "vercel" + // }); + + // router.push("/integrations/" + localStorage.getItem("projectData.id")); + } + + try { + + } catch (err) { + console.error('Netlify integration error: ', err); + } + + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return
; +} + +Netlify.requireAuth = true; diff --git a/frontend/pages/vercel.js b/frontend/pages/vercel.js index 7005a2590..a674c27d4 100644 --- a/frontend/pages/vercel.js +++ b/frontend/pages/vercel.js @@ -10,21 +10,14 @@ export default function Vercel() { const code = parsedUrl.code; const state = parsedUrl.state - // modify comment here - /** * Here we forward to the default workspace if a user opens this url */ // eslint-disable-next-line react-hooks/exhaustive-deps useEffect(async () => { - console.log('parsedUrl for vercel ', parsedUrl); if (state === localStorage.getItem('latestCSRFToken')) { localStorage.removeItem('latestCSRFToken'); - console.log('integ'); - console.log('code', code); - console.log('state', state); - await AuthorizeIntegration({ workspaceId: localStorage.getItem('projectData.id'), code, @@ -33,10 +26,7 @@ export default function Vercel() { router.push("/integrations/" + localStorage.getItem("projectData.id")); } - // parsedUrl.code - // parsedUrl.configurationId - // parsedUrl.next - // parsedUrl.state + try { } catch (err) { diff --git a/frontend/public/json/cloudIntegrations.json b/frontend/public/json/cloudIntegrations.json index 696be33de..a1679c993 100644 --- a/frontend/public/json/cloudIntegrations.json +++ b/frontend/public/json/cloudIntegrations.json @@ -9,18 +9,18 @@ }, { "name": "Vercel", - "slug": "netlify", - "image": "Netlify", + "slug": "vercel", + "image": "Vercel", "isAvailable": true, "type": "vercel", "clientId": "" }, { - "name": "Digital Ocean", - "slug": "digital-ocean", - "image": "Digital Ocean", - "isAvailable": false, - "type": "", + "name": "Netlify", + "slug": "netlify", + "image": "Netlify", + "isAvailable": true, + "type": "oauth2", "clientId": "" }, { From fe17d8459b117f02a1052ce4dd3157fd45e5c292 Mon Sep 17 00:00:00 2001 From: Tuan Dang Date: Tue, 13 Dec 2022 21:12:40 -0500 Subject: [PATCH 16/25] Begin Netlify integration --- backend/src/config/index.ts | 8 ++- backend/src/helpers/integration.ts | 4 +- backend/src/integrations/exchange.ts | 66 ++++++++++++++++++++- backend/src/variables/integration.ts | 2 +- frontend/pages/integrations/[id].js | 33 +++++++++-- frontend/pages/netlify.js | 15 +++-- frontend/public/json/cloudIntegrations.json | 3 +- 7 files changed, 114 insertions(+), 17 deletions(-) diff --git a/backend/src/config/index.ts b/backend/src/config/index.ts index 83d2bacdc..ea5f653a3 100644 --- a/backend/src/config/index.ts +++ b/backend/src/config/index.ts @@ -11,8 +11,10 @@ const JWT_SIGNUP_SECRET = process.env.JWT_SIGNUP_SECRET!; const MONGO_URL = process.env.MONGO_URL!; const NODE_ENV = process.env.NODE_ENV! || 'production'; const OAUTH_CLIENT_SECRET_HEROKU = process.env.OAUTH_CLIENT_SECRET_HEROKU!; -const CLIENT_SECRET_VERCEL = process.env.CLIENT_SECRET_VERCEL!; const CLIENT_ID_VERCEL = process.env.CLIENT_ID_VERCEL!; +const CLIENT_ID_NETLIFY = process.env.CLIENT_ID_NETLIFY!; +const CLIENT_SECRET_VERCEL = process.env.CLIENT_SECRET_VERCEL!; +const CLIENT_SECRET_NETLIFY = process.env.CLIENT_SECRET_NETLIFY!; const POSTHOG_HOST = process.env.POSTHOG_HOST! || 'https://app.posthog.com'; const POSTHOG_PROJECT_API_KEY = process.env.POSTHOG_PROJECT_API_KEY! || @@ -48,8 +50,10 @@ export { MONGO_URL, NODE_ENV, OAUTH_CLIENT_SECRET_HEROKU, - CLIENT_SECRET_VERCEL, CLIENT_ID_VERCEL, + CLIENT_ID_NETLIFY, + CLIENT_SECRET_VERCEL, + CLIENT_SECRET_NETLIFY, POSTHOG_HOST, POSTHOG_PROJECT_API_KEY, PRIVATE_KEY, diff --git a/backend/src/helpers/integration.ts b/backend/src/helpers/integration.ts index 0c4bd4949..aae9d4b00 100644 --- a/backend/src/helpers/integration.ts +++ b/backend/src/helpers/integration.ts @@ -55,8 +55,8 @@ const handleOAuthExchangeHelper = async ({ integration, code }); - - // TODO: continue ironing out Vercel integration + + return; let update: Update = { workspace: workspaceId, diff --git a/backend/src/integrations/exchange.ts b/backend/src/integrations/exchange.ts index 9ce670144..1c764c1da 100644 --- a/backend/src/integrations/exchange.ts +++ b/backend/src/integrations/exchange.ts @@ -3,15 +3,19 @@ import * as Sentry from '@sentry/node'; import { INTEGRATION_HEROKU, INTEGRATION_VERCEL, + INTEGRATION_NETLIFY, INTEGRATION_HEROKU_TOKEN_URL, INTEGRATION_VERCEL_TOKEN_URL, + INTEGRATION_NETLIFY_TOKEN_URL, ACTION_PUSH_TO_HEROKU } from '../variables'; import { SITE_URL, OAUTH_CLIENT_SECRET_HEROKU, CLIENT_ID_VERCEL, - CLIENT_SECRET_VERCEL + CLIENT_ID_NETLIFY, + CLIENT_SECRET_VERCEL, + CLIENT_SECRET_NETLIFY } from '../config'; interface ExchangeCodeHerokuResponse { @@ -63,6 +67,12 @@ const exchangeCode = async ({ obj = await exchangeCodeVercel({ code }); + break; + case INTEGRATION_NETLIFY: + obj = await exchangeCodeNetlify({ + code + }); + break; } } catch (err) { Sentry.setUser(null); @@ -157,6 +167,60 @@ const exchangeCodeVercel = async ({ }); } +/** + * Return [accessToken], [accessExpiresAt], and [refreshToken] for Vercel + * 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 Heroku API + * @returns {String} obj2.refreshToken - refresh token for Heroku API + * @returns {Date} obj2.accessExpiresAt - date of expiration for access token + */ +const exchangeCodeNetlify = async ({ + code +}: { + code: string; +}) => { + console.log('exchangeCodeNetlify'); + let res: ExchangeCodeVercelResponse; + try { + res = (await axios.post( + INTEGRATION_VERCEL_TOKEN_URL, + new URLSearchParams({ + code: code, + client_id: CLIENT_ID_VERCEL, + client_secret: CLIENT_SECRET_VERCEL, + redirect_uri: `${SITE_URL}/vercel` + } as any) + )).data; + + res = (await axios.post( + INTEGRATION_NETLIFY_TOKEN_URL, + `${"https://api.netlify.com/oauth/token"}?code=${code}&client_id=${CLIENT_ID_NETLIFY}&client_secret=${CLIENT_SECRET_NETLIFY}&grant_type=authorization_code&redirect_uri=${SITE_URL}/netlify` + // INTEGRATION_NETLIFY_TOKEN_URL, + // new URLSearchParams({ + // code: code, + // client_id: CLIENT_ID_NETLIFY, + // client_secret: CLIENT_SECRET_NETLIFY, + // redirect_uri: `${SITE_URL}/netlify` + // } as any) + )); + + console.log('resss', res); + + } catch (err) { + console.error('netlify err', err); + Sentry.setUser(null); + Sentry.captureException(err); + throw new Error('Failed OAuth2 code-token exchange with Netlify'); + } + + return ({ + + }); +} + export { exchangeCode } \ No newline at end of file diff --git a/backend/src/variables/integration.ts b/backend/src/variables/integration.ts index da45336fc..1dd721a11 100644 --- a/backend/src/variables/integration.ts +++ b/backend/src/variables/integration.ts @@ -14,7 +14,7 @@ const INTEGRATION_OAUTH2 = 'oauth2'; // integration oauth endpoints const INTEGRATION_HEROKU_TOKEN_URL = 'https://id.heroku.com/oauth/token'; const INTEGRATION_VERCEL_TOKEN_URL = 'https://api.vercel.com/v2/oauth/access_token'; -const INTEGRATION_NETLIFY_TOKEN_URL = 'https://app.netlify.com/authorize'; +const INTEGRATION_NETLIFY_TOKEN_URL = 'https://api.netlify.com/oauth/token'; // integration apps endpoints const INTEGRATION_HEROKU_API_URL = 'https://api.heroku.com'; diff --git a/frontend/pages/integrations/[id].js b/frontend/pages/integrations/[id].js index 864229d72..f8a8e3de0 100644 --- a/frontend/pages/integrations/[id].js +++ b/frontend/pages/integrations/[id].js @@ -21,6 +21,8 @@ const { } = require('../../components/utilities/cryptography/crypto'); const crypto = require("crypto"); +import axios from "axios"; + export default function Integrations() { const [integrationAuths, setIntegrationAuths] = useState([]); const [integrations, setIntegrations] = useState([]); @@ -115,20 +117,41 @@ export default function Integrations() { const handleIntegrationOption = async ({ integrationOption }) => { // TODO: modularize and handle switch by slug + console.log('handle', integrationOption); + // generate CSRF token for OAuth2 code-token exchange integrations - const csrfToken = crypto.randomBytes(16).toString("hex"); - localStorage.setItem('latestCSRFToken', csrfToken); + const state = crypto.randomBytes(16).toString("hex"); + localStorage.setItem('latestCSRFToken', state); switch (integrationOption.name) { case 'Heroku': // console.log('Heroku integration ', integrationOption); - window.location = `https://id.heroku.com/oauth/authorize?client_id=${integrationOption.clientId}&response_type=code&scope=write-protected&state=${csrfToken}`; + window.location = `https://id.heroku.com/oauth/authorize?client_id=${integrationOption.clientId}&response_type=code&scope=write-protected&state=${state}`; break; case 'Vercel': - window.location = `https://vercel.com/integrations/infisical/new?state=${csrfToken}`; + window.location = `https://vercel.com/integrations/infisical/new?state=${state}`; break; case 'Netlify': - console.log('netlifyyy'); + // window.location = `https://app.netlify.com/authorize?client_id=${integrationOption.clientId}&response_type=token&redirect_uri=${integrationOption.redirectURL}&state=${state}`; + window.location = `https://app.netlify.com/authorize?client_id=${integrationOption.clientId}&response_type=code&redirect_uri=${integrationOption.redirectURL}&state=${state}`; + // const res = await axios.post('https://api.netlify.com/api/v1/oauth/tickets' + '?client_id=' + integrationOption.clientId); + + // window.location = `https://app.netlify.com/authorize?client_id=${integrationOption.clientId}&response_type=ticket&redirect_uri=${integrationOption.redirectURL}&state=${state}&ticket=${res.data.id}`; + // `https://app.netlify.com/authorize?response_type=ticket&ticket=${ticket.id}` + try { + // const res = await axios.post('https://api.netlify.com/api/v1/oauth/tickets' + '?client_id=' + integrationOption.clientId); + // console.log('res response', res); + // const res2 = await axios.get('https://api.netlify.com/api/v1/oauth/tickets/' + res.data.id); + // console.log('res2 response', res2); + // console.log('ticket_id', res.data.id); + // // exchange ticket: + // const res3 = await axios.get(`https://api.netlify.com/api/v1/oauth/tickets/${res.data.id}/exchange`); + // console.log('res3 response', res3); + + } catch (err) { + console.error('Netlify ', err); + } + break; } } diff --git a/frontend/pages/netlify.js b/frontend/pages/netlify.js index 06d6ab32b..4217ba54d 100644 --- a/frontend/pages/netlify.js +++ b/frontend/pages/netlify.js @@ -7,6 +7,8 @@ import AuthorizeIntegration from "./api/integrations/authorizeIntegration"; export default function Netlify() { const router = useRouter(); const parsedUrl = queryString.parse(router.asPath.split("?")[1]); + const code = parsedUrl.code; + const state = parsedUrl.state; // modify comment here /** @@ -14,16 +16,19 @@ export default function Netlify() { */ // eslint-disable-next-line react-hooks/exhaustive-deps useEffect(async () => { + console.log('AA'); if (state === localStorage.getItem('latestCSRFToken')) { localStorage.removeItem('latestCSRFToken'); console.log('Netlify', parsedUrl); + // http://localhost:8080/netlify?code=qnG0g_krhklWDpdUqfhU-t1sLeZzYI3gF2d6QVnL-Gc&state=3a78cd3154a9a99ddd4eb5d99dbb3289 + // http://localhost:8080/netlify#access_token=d-78qUAnnSzvlgfG9Y_oUV6_4TQBxLbofImiBbKAjzE&token_type=Bearer&state=5da34fa49e301e9fa1a6e40925694b77 - // await AuthorizeIntegration({ - // workspaceId: localStorage.getItem('projectData.id'), - // code, - // integration: "vercel" - // }); + await AuthorizeIntegration({ + workspaceId: localStorage.getItem('projectData.id'), + code, + integration: "netlify" + }); // router.push("/integrations/" + localStorage.getItem("projectData.id")); } diff --git a/frontend/public/json/cloudIntegrations.json b/frontend/public/json/cloudIntegrations.json index a1679c993..48a525a7e 100644 --- a/frontend/public/json/cloudIntegrations.json +++ b/frontend/public/json/cloudIntegrations.json @@ -21,7 +21,8 @@ "image": "Netlify", "isAvailable": true, "type": "oauth2", - "clientId": "" + "clientId": "frGheMeEzVEUgM5yHLcPeTj9kYJhYvqkR5IkSexOS50", + "redirectURL": "http://localhost:8080/netlify" }, { "name": "Google Cloud Platform", From 787e54fb91bb26f55e9097ddc44fd5a389bd1134 Mon Sep 17 00:00:00 2001 From: Tuan Dang Date: Wed, 14 Dec 2022 18:18:21 -0500 Subject: [PATCH 17/25] Finish Netlify integration v1 full-loop --- .../controllers/integrationAuthController.ts | 2 +- .../src/controllers/integrationController.ts | 15 +- backend/src/helpers/bot.ts | 1 + backend/src/helpers/integration.ts | 23 +- backend/src/integrations/apps.ts | 69 +++++- backend/src/integrations/exchange.ts | 57 +++-- backend/src/integrations/sync.ts | 228 +++++++++++++++--- backend/src/models/integration.ts | 12 +- backend/src/models/integrationAuth.ts | 8 +- backend/src/routes/integration.ts | 3 + .../components/integrations/Integration.tsx | 102 ++++++-- .../api/integrations/updateIntegration.js | 12 +- frontend/pages/netlify.js | 2 - frontend/public/data/frequentConstants.ts | 15 +- 14 files changed, 441 insertions(+), 108 deletions(-) diff --git a/backend/src/controllers/integrationAuthController.ts b/backend/src/controllers/integrationAuthController.ts index f38516e30..b5c9167aa 100644 --- a/backend/src/controllers/integrationAuthController.ts +++ b/backend/src/controllers/integrationAuthController.ts @@ -51,7 +51,7 @@ export const getIntegrationAuthApps = async (req: Request, res: Response) => { let apps; try { apps = await getApps({ - integration: req.integrationAuth.integration, + integrationAuth: req.integrationAuth, accessToken: req.accessToken }); } catch (err) { diff --git a/backend/src/controllers/integrationController.ts b/backend/src/controllers/integrationController.ts index 87ee77dbe..910c7e825 100644 --- a/backend/src/controllers/integrationController.ts +++ b/backend/src/controllers/integrationController.ts @@ -35,8 +35,15 @@ export const updateIntegration = async (req: Request, res: Response) => { // integration has the correct fields populated in [Integration] try { - const { app, environment, isActive, target } = req.body; - + const { + app, + environment, + isActive, + target, // vercel-specific integration param + context, // netlify-specific integration param + siteId // netlify-specific integration param + } = req.body; + integration = await Integration.findOneAndUpdate( { _id: req.integration._id @@ -45,7 +52,9 @@ export const updateIntegration = async (req: Request, res: Response) => { environment, isActive, app, - target + target, + context, + siteId }, { new: true diff --git a/backend/src/helpers/bot.ts b/backend/src/helpers/bot.ts index 3235a488d..3285ccd6f 100644 --- a/backend/src/helpers/bot.ts +++ b/backend/src/helpers/bot.ts @@ -74,6 +74,7 @@ const getSecretsHelper = async ({ const key = await getKey({ workspaceId }); const secrets = await Secret.find({ workspaceId, + environment, type: SECRET_SHARED }); diff --git a/backend/src/helpers/integration.ts b/backend/src/helpers/integration.ts index aae9d4b00..9aaff9741 100644 --- a/backend/src/helpers/integration.ts +++ b/backend/src/helpers/integration.ts @@ -11,13 +11,15 @@ import { BotService, IntegrationService } from '../services'; import { ENV_DEV, EVENT_PUSH_SECRETS, - INTEGRATION_VERCEL + INTEGRATION_VERCEL, + INTEGRATION_NETLIFY } from '../variables'; interface Update { workspace: string; integration: string; teamId?: string; + accountId?: string; } /** @@ -55,8 +57,6 @@ const handleOAuthExchangeHelper = async ({ integration, code }); - - return; let update: Update = { workspace: workspaceId, @@ -67,6 +67,9 @@ const handleOAuthExchangeHelper = async ({ case INTEGRATION_VERCEL: update.teamId = res.teamId; break; + case INTEGRATION_NETLIFY: + update.accountId = res.accountId; + break; } integrationAuth = await IntegrationAuth.findOneAndUpdate({ @@ -124,11 +127,12 @@ const syncIntegrationsHelper = async ({ }) => { let integrations; try { + integrations = await Integration.find({ workspace: workspaceId, isActive: true, app: { $ne: null } - }).populate<{integrationAuth: IIntegrationAuth}>('integrationAuth', 'accessToken'); + }); // for each workspace integration, sync/push secrets // to that integration @@ -139,22 +143,23 @@ const syncIntegrationsHelper = async ({ environment: integration.environment }); + const integrationAuth = await IntegrationAuth.findById(integration.integrationAuth); + if (!integrationAuth) throw new Error('Failed to find integration auth'); + // get integration auth access token const accessToken = await getIntegrationAuthAccessHelper({ - integrationAuthId: integration.integrationAuth._id.toString() + integrationAuthId: integration.integrationAuth.toString() }); // sync secrets to integration await syncSecrets({ - integration: integration.integration, - app: integration.app, - target: integration.target, + integration, + integrationAuth, secrets, accessToken }); } } catch (err) { - console.log('syncIntegrationsHelper error', err); Sentry.setUser(null); Sentry.captureException(err); throw new Error('Failed to sync secrets to integrations'); diff --git a/backend/src/integrations/apps.ts b/backend/src/integrations/apps.ts index f25521481..70680ef7d 100644 --- a/backend/src/integrations/apps.ts +++ b/backend/src/integrations/apps.ts @@ -1,10 +1,15 @@ import axios from 'axios'; import * as Sentry from '@sentry/node'; +import { + IIntegrationAuth +} from '../models'; import { INTEGRATION_HEROKU, INTEGRATION_VERCEL, + INTEGRATION_NETLIFY, INTEGRATION_HEROKU_API_URL, - INTEGRATION_VERCEL_API_URL + INTEGRATION_VERCEL_API_URL, + INTEGRATION_NETLIFY_API_URL } from '../variables'; /** @@ -16,15 +21,21 @@ import { * @returns {String} apps.name - name of integration app */ const getApps = async ({ - integration, + integrationAuth, accessToken }: { - integration: string; + integrationAuth: IIntegrationAuth; accessToken: string; }) => { - let apps; + + interface App { + name: string; + siteId?: string; + } + + let apps: App[]; // TODO: add type and define payloads for apps try { - switch (integration) { + switch (integrationAuth.integration) { case INTEGRATION_HEROKU: apps = await getAppsHeroku({ accessToken @@ -35,6 +46,12 @@ const getApps = async ({ accessToken }); break; + case INTEGRATION_NETLIFY: + apps = await getAppsNetlify({ + integrationAuth, + accessToken + }); + break; } } catch (err) { @@ -82,9 +99,9 @@ const getAppsHeroku = async ({ /** * Return list of names of apps for Vercel 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 + * @param {String} obj.accessToken - access token for Vercel API + * @returns {Object[]} apps - names of Vercel apps + * @returns {String} apps.name - name of Vercel app */ const getAppsVercel = async ({ accessToken @@ -111,6 +128,42 @@ const getAppsVercel = async ({ return apps; } +/** + * Return list of names of sites for Netlify integration + * @param {Object} obj + * @param {String} obj.accessToken - access token for Netlify API + * @returns {Object[]} apps - names of Netlify sites + * @returns {String} apps.name - name of Netlify site + */ +const getAppsNetlify = async ({ + integrationAuth, + accessToken +}: { + integrationAuth: IIntegrationAuth; + accessToken: string; +}) => { + let apps; + try { + const res = (await axios.get(`${INTEGRATION_NETLIFY_API_URL}/api/v1/sites`, { + headers: { + Authorization: `Bearer ${accessToken}` + } + })).data; + + apps = res.map((a: any) => ({ + name: a.name, + siteId: a.site_id + })); + + } catch (err) { + Sentry.setUser(null); + Sentry.captureException(err); + throw new Error('Failed to get Netlify integration apps'); + } + + return apps; +} + export { getApps } \ No newline at end of file diff --git a/backend/src/integrations/exchange.ts b/backend/src/integrations/exchange.ts index 1c764c1da..5b7bc71ae 100644 --- a/backend/src/integrations/exchange.ts +++ b/backend/src/integrations/exchange.ts @@ -35,6 +35,14 @@ interface ExchangeCodeVercelResponse { team_id?: string; } +interface ExchangeCodeNetlifyResponse { + access_token: string; + token_type: string; + refresh_token: string; + scope: string; + created_at: number; +} + /** * Return [accessToken], [accessExpiresAt], and [refreshToken] for OAuth2 * code-token exchange for integration named [integration] @@ -152,7 +160,6 @@ const exchangeCodeVercel = async ({ redirect_uri: `${SITE_URL}/vercel` } as any) )).data; - } catch (err) { Sentry.setUser(null); Sentry.captureException(err); @@ -182,42 +189,50 @@ const exchangeCodeNetlify = async ({ }: { code: string; }) => { - console.log('exchangeCodeNetlify'); - let res: ExchangeCodeVercelResponse; + let res: ExchangeCodeNetlifyResponse; + let accountId; try { res = (await axios.post( - INTEGRATION_VERCEL_TOKEN_URL, + INTEGRATION_NETLIFY_TOKEN_URL, new URLSearchParams({ + grant_type: 'authorization_code', code: code, - client_id: CLIENT_ID_VERCEL, - client_secret: CLIENT_SECRET_VERCEL, - redirect_uri: `${SITE_URL}/vercel` + client_id: CLIENT_ID_NETLIFY, + client_secret: CLIENT_SECRET_NETLIFY, + redirect_uri: `${SITE_URL}/netlify` } as any) )).data; - res = (await axios.post( - INTEGRATION_NETLIFY_TOKEN_URL, - `${"https://api.netlify.com/oauth/token"}?code=${code}&client_id=${CLIENT_ID_NETLIFY}&client_secret=${CLIENT_SECRET_NETLIFY}&grant_type=authorization_code&redirect_uri=${SITE_URL}/netlify` - // INTEGRATION_NETLIFY_TOKEN_URL, - // new URLSearchParams({ - // code: code, - // client_id: CLIENT_ID_NETLIFY, - // client_secret: CLIENT_SECRET_NETLIFY, - // redirect_uri: `${SITE_URL}/netlify` - // } as any) - )); + const res2 = await axios.get( + 'https://api.netlify.com/api/v1/sites', + { + headers: { + Authorization: `Bearer ${res.access_token}` + } + } + ); - console.log('resss', res); + const res3 = (await axios.get( + 'https://api.netlify.com/api/v1/accounts', + { + headers: { + Authorization: `Bearer ${res.access_token}` + } + } + )).data; + accountId = res3[0].id; + } catch (err) { - console.error('netlify err', err); Sentry.setUser(null); Sentry.captureException(err); throw new Error('Failed OAuth2 code-token exchange with Netlify'); } return ({ - + accessToken: res.access_token, + refreshToken: res.refresh_token, + accountId }); } diff --git a/backend/src/integrations/sync.ts b/backend/src/integrations/sync.ts index 930721d72..3cef519cc 100644 --- a/backend/src/integrations/sync.ts +++ b/backend/src/integrations/sync.ts @@ -1,10 +1,15 @@ import axios from 'axios'; import * as Sentry from '@sentry/node'; +import { + IIntegration, IIntegrationAuth +} from '../models'; import { INTEGRATION_HEROKU, INTEGRATION_VERCEL, + INTEGRATION_NETLIFY, INTEGRATION_HEROKU_API_URL, - INTEGRATION_VERCEL_API_URL + INTEGRATION_VERCEL_API_URL, + INTEGRATION_NETLIFY_API_URL } from '../variables'; // TODO: need a helper function in the future to handle integration @@ -13,7 +18,8 @@ import { /** * Sync/push [secrets] to [app] in integration named [integration] * @param {Object} obj - * @param {Object} obj.integration - name of integration + * @param {IIntegration} obj.integration - integration details + * @param {IIntegrationAuth} obj.integrationAuth - integration auth details * @param {Object} obj.app - app in integration * @param {Object} obj.target - (optional) target (environment) in integration * @param {Object} obj.secrets - secrets to push to integration (object where keys are secret keys and values are secret values) @@ -21,33 +27,39 @@ import { */ const syncSecrets = async ({ integration, - app, - target, + integrationAuth, secrets, accessToken, }: { - integration: string; - app: string; - target: string; + integration: IIntegration; + integrationAuth: IIntegrationAuth; secrets: any; accessToken: string; }) => { try { - switch (integration) { + switch (integration.integration) { case INTEGRATION_HEROKU: await syncSecretsHeroku({ - app, + integration, secrets, accessToken }); break; case INTEGRATION_VERCEL: await syncSecretsVercel({ - app, - target, + integration, secrets, accessToken }); + break; + case INTEGRATION_NETLIFY: + await syncSecretsNetlify({ + integration, + integrationAuth, + secrets, + accessToken + }); + break; } } catch (err) { Sentry.setUser(null); @@ -59,21 +71,21 @@ const syncSecrets = async ({ /** * Sync/push [secrets] to Heroku [app] * @param {Object} obj - * @param {String} obj.app - app in integration + * @param {IIntegration} obj.integration - integration details * @param {Object} obj.secrets - secrets to push to integration (object where keys are secret keys and values are secret values) */ const syncSecretsHeroku = async ({ - app, + integration, secrets, accessToken }: { - app: string; + integration: IIntegration, secrets: any; accessToken: string; }) => { try { const herokuSecrets = (await axios.get( - `${INTEGRATION_HEROKU_API_URL}/apps/${app}/config-vars`, + `${INTEGRATION_HEROKU_API_URL}/apps/${integration.app}/config-vars`, { headers: { Accept: 'application/vnd.heroku+json; version=3', @@ -89,7 +101,7 @@ const syncSecretsHeroku = async ({ }); await axios.patch( - `${INTEGRATION_HEROKU_API_URL}/apps/${app}/config-vars`, + `${INTEGRATION_HEROKU_API_URL}/apps/${integration.app}/config-vars`, secrets, { headers: { @@ -108,18 +120,15 @@ const syncSecretsHeroku = async ({ /** * Sync/push [secrets] to Heroku [app] * @param {Object} obj - * @param {String} obj.app - app in integration - * @param {String} obj.target - (optional) target (environment) in integration + * @param {IIntegration} obj.integration - integration details * @param {Object} obj.secrets - secrets to push to integration (object where keys are secret keys and values are secret values) */ const syncSecretsVercel = async ({ - app, - target, + integration, secrets, accessToken }: { - app: string; - target: string; + integration: IIntegration, secrets: any; accessToken: string; }) => { @@ -140,7 +149,7 @@ const syncSecretsVercel = async ({ }); const res = (await Promise.all((await axios.get( - `${INTEGRATION_VERCEL_API_URL}/v9/projects/${app}/env`, + `${INTEGRATION_VERCEL_API_URL}/v9/projects/${integration.app}/env`, { params, headers: { @@ -150,9 +159,9 @@ const syncSecretsVercel = async ({ )) .data .envs - .filter((secret: VercelSecret) => secret.target.includes(target)) + .filter((secret: VercelSecret) => secret.target.includes(integration.target)) .map(async (secret: VercelSecret) => (await axios.get( - `${INTEGRATION_VERCEL_API_URL}/v9/projects/${app}/env/${secret.id}`, + `${INTEGRATION_VERCEL_API_URL}/v9/projects/${integration.app}/env/${secret.id}`, { headers: { Authorization: `Bearer ${accessToken}` @@ -172,11 +181,12 @@ const syncSecretsVercel = async ({ // Identify secrets to create Object.keys(secrets).map((key) => { if (!(key in res)) { + // case: secret has been created newSecrets.push({ key: key, value: secrets[key], type: 'encrypted', - target: [target] + target: [integration.target] }); } }); @@ -191,7 +201,7 @@ const syncSecretsVercel = async ({ key: key, value: secrets[key], type: 'encrypted', - target: [target] + target: [integration.target] }); } } else { @@ -201,7 +211,7 @@ const syncSecretsVercel = async ({ key: key, value: res[key].value, type: 'encrypted', - target: [target], + target: [integration.target], }); } }); @@ -209,7 +219,7 @@ const syncSecretsVercel = async ({ // Sync/push new secrets if (newSecrets.length > 0) { await axios.post( - `${INTEGRATION_VERCEL_API_URL}/v10/projects/${app}/env`, + `${INTEGRATION_VERCEL_API_URL}/v10/projects/${integration.app}/env`, newSecrets, { headers: { @@ -227,7 +237,7 @@ const syncSecretsVercel = async ({ ...updatedSecret } = secret; await axios.patch( - `${INTEGRATION_VERCEL_API_URL}/v9/projects/${app}/env/${secret.id}`, + `${INTEGRATION_VERCEL_API_URL}/v9/projects/${integration.app}/env/${secret.id}`, updatedSecret, { headers: { @@ -242,7 +252,7 @@ const syncSecretsVercel = async ({ if (deleteSecrets.length > 0) { deleteSecrets.forEach(async (secret: VercelSecret) => { await axios.delete( - `${INTEGRATION_VERCEL_API_URL}/v9/projects/${app}/env/${secret.id}`, + `${INTEGRATION_VERCEL_API_URL}/v9/projects/${integration.app}/env/${secret.id}`, { headers: { Authorization: `Bearer ${accessToken}` @@ -258,6 +268,162 @@ const syncSecretsVercel = async ({ } } +/** + * Sync/push [secrets] to Netlify site [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) + */ +const syncSecretsNetlify = async ({ + integration, + integrationAuth, + secrets, + accessToken +}: { + integration: IIntegration; + integrationAuth: IIntegrationAuth; + secrets: any; + accessToken: string; +}) => { + try { + const getParams = new URLSearchParams({ + context_name: integration.context, + site_id: integration.siteId + }); + + const res = (await axios.get( + `${INTEGRATION_NETLIFY_API_URL}/api/v1/accounts/${integrationAuth.accountId}/env`, + { + params: getParams, + headers: { + Authorization: `Bearer ${accessToken}` + } + } + )) + .data + .reduce((obj: any, secret: any) => ({ + ...obj, + [secret.key]: secret.values[0].value + }), {}); + + interface UpdateNetlifySecret { + key: string; + context: string; + value: string; + } + + interface DeleteNetlifySecret { + key: string; + } + + interface NewNetlifySecretValue { + value: string; + context: string; + } + + interface NewNetlifySecret { + key: string; + values: NewNetlifySecretValue[]; + } + + let updateSecrets: UpdateNetlifySecret[] = []; + let deleteSecrets: DeleteNetlifySecret[] = []; + let newSecrets: NewNetlifySecret[] = []; + + // Identify secrets to create + Object.keys(secrets).map((key) => { + if (!(key in res)) { + // case: secret has been created + newSecrets.push({ + key: key, + values: [{ + value: secrets[key], // include id? + context: integration.context + }] + }); + } + }); + + // Identify secrets to update and delete + Object.keys(res).map((key) => { + if (key in secrets) { + if (res[key] !== secrets[key]) { + // case: secret value has changed + updateSecrets.push({ + key: key, + context: integration.context, + value: secrets[key] + }); + } + } else { + // case: secret has been deleted + deleteSecrets.push({ + key + }); + } + }); + + const syncParams = new URLSearchParams({ + site_id: integration.siteId + }); + + // Sync/push new secrets + if (newSecrets.length > 0) { + await axios.post( + `${INTEGRATION_NETLIFY_API_URL}/api/v1/accounts/${integrationAuth.accountId}/env`, + newSecrets, + { + params: syncParams, + headers: { + Authorization: `Bearer ${accessToken}` + } + } + ); + } + + // Sync/push updated secrets + if (updateSecrets.length > 0) { + + updateSecrets.forEach(async (secret: UpdateNetlifySecret) => { + await axios.patch( + `${INTEGRATION_NETLIFY_API_URL}/api/v1/accounts/${integrationAuth.accountId}/env/${secret.key}`, + { + context: secret.context, + value: secret.value + }, + { + params: syncParams, + headers: { + Authorization: `Bearer ${accessToken}` + } + } + ); + }); + } + + // Delete secrets + if (deleteSecrets.length > 0) { + deleteSecrets.forEach(async (secret: DeleteNetlifySecret) => { + await axios.delete( + `${INTEGRATION_NETLIFY_API_URL}/api/v1/accounts/${integrationAuth.accountId}/env/${secret.key}`, + { + params: syncParams, + headers: { + Authorization: `Bearer ${accessToken}` + } + } + ); + }); + } + + } catch (err) { + Sentry.setUser(null); + Sentry.captureException(err); + throw new Error('Failed to sync secrets to Heroku'); + } +} + export { syncSecrets } \ No newline at end of file diff --git a/backend/src/models/integration.ts b/backend/src/models/integration.ts index eb82b95ca..edbe0234e 100644 --- a/backend/src/models/integration.ts +++ b/backend/src/models/integration.ts @@ -16,7 +16,9 @@ export interface IIntegration { isActive: boolean; app: string; target: string; - integration: 'heroku' | 'netlify'; + context: string; + siteId: string; + integration: 'heroku' | 'vercel' | 'netlify'; integrationAuth: Types.ObjectId; } @@ -44,6 +46,14 @@ const integrationSchema = new Schema( type: String, default: null }, + context: { // netlify-specific context (deploy) + type: String, + default: null + }, + siteId: { // netlify-specific site (app) id + type: String, + default: null + }, integration: { type: String, enum: [ diff --git a/backend/src/models/integrationAuth.ts b/backend/src/models/integrationAuth.ts index b03a73e52..0da3eb0d8 100644 --- a/backend/src/models/integrationAuth.ts +++ b/backend/src/models/integrationAuth.ts @@ -8,8 +8,9 @@ import { export interface IIntegrationAuth { _id: Types.ObjectId; workspace: Types.ObjectId; - integration: 'heroku' | 'netlify'; + integration: 'heroku' | 'vercel' | 'netlify'; teamId: string; + accountId: string; refreshCiphertext?: string; refreshIV?: string; refreshTag?: string; @@ -34,7 +35,10 @@ const integrationAuthSchema = new Schema( ], required: true }, - teamId: { // vercel-specific integration param set at OAuth2 code-token exchange + teamId: { // vercel-specific integration param + type: String + }, + accountId: { // netlify-specific integration param type: String }, refreshCiphertext: { diff --git a/backend/src/routes/integration.ts b/backend/src/routes/integration.ts index 88eebaa5f..e6738a803 100644 --- a/backend/src/routes/integration.ts +++ b/backend/src/routes/integration.ts @@ -20,6 +20,9 @@ router.patch( body('app').exists().trim(), body('environment').exists().trim(), body('isActive').exists().isBoolean(), + body('target').exists(), + body('context').exists(), + body('siteId').exists(), validateRequest, integrationController.updateIntegration ); diff --git a/frontend/components/integrations/Integration.tsx b/frontend/components/integrations/Integration.tsx index 8dabf9246..8a323336e 100644 --- a/frontend/components/integrations/Integration.tsx +++ b/frontend/components/integrations/Integration.tsx @@ -8,7 +8,8 @@ import { import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { envMapping, - reverseEnvMapping + reverseEnvMapping, + reverseContextNetlifyMapping } from "../../public/data/frequentConstants"; import updateIntegration from "../../pages/api/integrations/updateIntegration" import deleteIntegration from "../../pages/api/integrations/DeleteIntegration" @@ -36,23 +37,85 @@ const Integration = ({ ); const [fileState, setFileState] = useState([]); const router = useRouter(); - const [apps, setApps] = useState([]); - const [integrationApp, setIntegrationApp] = useState(null); - const [integrationTarget, setIntegrationTarget] = useState(null); + const [apps, setApps] = useState([]); // integration app objects + const [integrationApp, setIntegrationApp] = useState(null); // integration app name + const [integrationTarget, setIntegrationTarget] = useState(null); // vercel-specific integration param + const [integrationContext, setIntegrationContext] = useState(null); // netlify-specific integration param useEffect(async () => { + interface App { + name: string; + siteId?: string; + } + const tempApps = await getIntegrationApps({ integrationAuthId: integration.integrationAuth, }); - const tempAppNames = tempApps.map((app) => app.name); - setApps(tempAppNames); + setApps(tempApps); setIntegrationApp( - integration.app ? integration.app : tempAppNames[0] + integration.app ? integration.app : tempApps[0].name ); - setIntegrationTarget("Development"); + + switch (integration.integration) { + case "vercel": + setIntegrationTarget("Development"); + break; + case "netlify": + setIntegrationContext("All"); + break; + default: + break; + } }, []); + const renderIntegrationSpecificParams = (integration) => { + try { + switch (integration.integration) { + case "vercel": + return ( +
+
+ ENVIRONMENT +
+ +
+ ); + case "netlify": + return ( +
+
+ CONTEXT +
+ +
+ ); + default: + return
; + } + } catch (err) { + console.error(err); + } + } + if (!integrationApp || apps.length === 0) return
return ( @@ -91,27 +154,12 @@ const Integration = ({ APP
app.name)} selected={integrationApp} onChange={setIntegrationApp} />
- {integration.integration === "vercel" && ( -
-
- ENVIRONMENT -
- -
- )} + {renderIntegrationSpecificParams(integration)}
{integration.isActive ? ( @@ -131,7 +179,9 @@ const Integration = ({ environment: envMapping[integrationEnvironment], app: integrationApp, isActive: true, - target: integrationTarget.toLowerCase() + target: integrationTarget ? integrationTarget.toLowerCase() : null, + context: integrationContext ? reverseContextNetlifyMapping[integrationContext] : null, + siteId: apps.find((app) => app.name === integrationApp).siteId }); router.reload(); }} diff --git a/frontend/pages/api/integrations/updateIntegration.js b/frontend/pages/api/integrations/updateIntegration.js index cb5288f5c..c77297e3a 100644 --- a/frontend/pages/api/integrations/updateIntegration.js +++ b/frontend/pages/api/integrations/updateIntegration.js @@ -9,7 +9,9 @@ import SecurityClient from "~/utilities/SecurityClient"; * @param {String} obj.app - name of app * @param {String} obj.environment - project environment to push secrets from * @param {Boolean} obj.isActive - active state - * @param {String} obj.target - (optional) target (environment) + * @param {String} obj.target - (optional) target (environment) for Vercel integration + * @param {String} obj.context - (optional) context (environment) for Netlify integration + * @param {String} obj.siteId - (optional) app (site_id) for Netlify integration * @returns */ const updateIntegration = ({ @@ -17,7 +19,9 @@ const updateIntegration = ({ app, environment, isActive, - target + target, + context, + siteId }) => { return SecurityClient.fetchCall( "/api/v1/integration/" + integrationId, @@ -30,7 +34,9 @@ const updateIntegration = ({ app, environment, isActive, - target + target, + context, + siteId }), } ).then(async (res) => { diff --git a/frontend/pages/netlify.js b/frontend/pages/netlify.js index 4217ba54d..4fbcf2061 100644 --- a/frontend/pages/netlify.js +++ b/frontend/pages/netlify.js @@ -16,11 +16,9 @@ export default function Netlify() { */ // eslint-disable-next-line react-hooks/exhaustive-deps useEffect(async () => { - console.log('AA'); if (state === localStorage.getItem('latestCSRFToken')) { localStorage.removeItem('latestCSRFToken'); - console.log('Netlify', parsedUrl); // http://localhost:8080/netlify?code=qnG0g_krhklWDpdUqfhU-t1sLeZzYI3gF2d6QVnL-Gc&state=3a78cd3154a9a99ddd4eb5d99dbb3289 // http://localhost:8080/netlify#access_token=d-78qUAnnSzvlgfG9Y_oUV6_4TQBxLbofImiBbKAjzE&token_type=Bearer&state=5da34fa49e301e9fa1a6e40925694b77 diff --git a/frontend/public/data/frequentConstants.ts b/frontend/public/data/frequentConstants.ts index d44706660..bbaa42ade 100644 --- a/frontend/public/data/frequentConstants.ts +++ b/frontend/public/data/frequentConstants.ts @@ -12,7 +12,20 @@ const reverseEnvMapping = { test: "Testing", }; +const vercelMapping = { + +} + +const reverseContextNetlifyMapping = { + "All": "all", + "Local development": "dev", + "Branch deploys": "branch-deploy", + "Deploy Previews": "deploy-preview", + "Production": "production" +} + export { envMapping, - reverseEnvMapping + reverseEnvMapping, + reverseContextNetlifyMapping }; From a49fcf49f1f9104e6cd0c3474a71c7ccf0788e47 Mon Sep 17 00:00:00 2001 From: Tuan Dang Date: Wed, 14 Dec 2022 18:43:21 -0500 Subject: [PATCH 18/25] Rotate test OAuth2 token --- frontend/pages/netlify.js | 5 +---- frontend/public/json/cloudIntegrations.json | 2 +- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/frontend/pages/netlify.js b/frontend/pages/netlify.js index 4fbcf2061..f25c6af83 100644 --- a/frontend/pages/netlify.js +++ b/frontend/pages/netlify.js @@ -18,9 +18,6 @@ export default function Netlify() { useEffect(async () => { if (state === localStorage.getItem('latestCSRFToken')) { localStorage.removeItem('latestCSRFToken'); - - // http://localhost:8080/netlify?code=qnG0g_krhklWDpdUqfhU-t1sLeZzYI3gF2d6QVnL-Gc&state=3a78cd3154a9a99ddd4eb5d99dbb3289 - // http://localhost:8080/netlify#access_token=d-78qUAnnSzvlgfG9Y_oUV6_4TQBxLbofImiBbKAjzE&token_type=Bearer&state=5da34fa49e301e9fa1a6e40925694b77 await AuthorizeIntegration({ workspaceId: localStorage.getItem('projectData.id'), @@ -28,7 +25,7 @@ export default function Netlify() { integration: "netlify" }); - // router.push("/integrations/" + localStorage.getItem("projectData.id")); + router.push("/integrations/" + localStorage.getItem("projectData.id")); } try { diff --git a/frontend/public/json/cloudIntegrations.json b/frontend/public/json/cloudIntegrations.json index 48a525a7e..aa988fb5f 100644 --- a/frontend/public/json/cloudIntegrations.json +++ b/frontend/public/json/cloudIntegrations.json @@ -21,7 +21,7 @@ "image": "Netlify", "isAvailable": true, "type": "oauth2", - "clientId": "frGheMeEzVEUgM5yHLcPeTj9kYJhYvqkR5IkSexOS50", + "clientId": "fYWBhD3gt0pT62UIwYrlGRcy--pPVLYIQad6ORrES9o", "redirectURL": "http://localhost:8080/netlify" }, { From 35fd1520e2748bf21cc4e0a55d4916057b824c6a Mon Sep 17 00:00:00 2001 From: Tuan Dang Date: Thu, 15 Dec 2022 15:27:01 -0500 Subject: [PATCH 19/25] Add integration auth revocation --- .../controllers/integrationAuthController.ts | 46 ++++----------- backend/src/integrations/index.ts | 4 +- backend/src/integrations/revoke.ts | 50 +++++++++++++++++ .../integrations/CloudIntegration.tsx | 5 +- frontend/pages/integrations/[id].js | 56 ++++++------------- 5 files changed, 86 insertions(+), 75 deletions(-) create mode 100644 backend/src/integrations/revoke.ts diff --git a/backend/src/controllers/integrationAuthController.ts b/backend/src/controllers/integrationAuthController.ts index b5c9167aa..fc2767e0a 100644 --- a/backend/src/controllers/integrationAuthController.ts +++ b/backend/src/controllers/integrationAuthController.ts @@ -5,7 +5,7 @@ import { readFileSync } from 'fs'; import { IntegrationAuth, Integration } from '../models'; import { INTEGRATION_SET, ENV_DEV } from '../variables'; import { IntegrationService } from '../services'; -import { getApps } from '../integrations'; +import { getApps, revokeAccess } from '../integrations'; /** * Perform OAuth2 code-token exchange as part of integration [integration] for workspace with id [workspaceId] @@ -74,46 +74,22 @@ export const getIntegrationAuthApps = async (req: Request, res: Response) => { * @returns */ export const deleteIntegrationAuth = async (req: Request, res: Response) => { - // TODO: unfinished - disable application via Heroku API and make compatible with other integration types try { const { integrationAuthId } = req.params; - // TODO: disable application via Heroku API; figure out what authorization id is - - const integrations = JSON.parse( - readFileSync('./src/json/integrations.json').toString() - ); - - let authorizationId; - switch (req.integrationAuth.integration) { - case 'heroku': - authorizationId = integrations.heroku.clientId; - } - - // not sure what authorizationId is? - // // revoke authorization - // const res2 = await axios.delete( - // `https://api.heroku.com/oauth/authorizations/${authorizationId}`, - // { - // headers: { - // 'Accept': 'application/vnd.heroku+json; version=3', - // 'Authorization': 'Bearer ' + req.accessToken - // } - // } - // ); - - const deletedIntegrationAuth = await IntegrationAuth.findOneAndDelete({ - _id: integrationAuthId + await revokeAccess({ + integrationAuth: req.integrationAuth, + accessToken: req.accessToken }); - - if (deletedIntegrationAuth) { - await Integration.deleteMany({ - integrationAuth: deletedIntegrationAuth._id - }); - } } catch (err) { + Sentry.setUser(null); + Sentry.captureException(err); return res.status(400).send({ message: 'Failed to delete integration authorization' }); } -}; + + return res.status(200).send({ + message: 'Successfully deleted integration authorization' + }); +} \ No newline at end of file diff --git a/backend/src/integrations/index.ts b/backend/src/integrations/index.ts index 3d8e8cc95..86c22de0c 100644 --- a/backend/src/integrations/index.ts +++ b/backend/src/integrations/index.ts @@ -2,10 +2,12 @@ import { exchangeCode } from './exchange'; import { exchangeRefresh } from './refresh'; import { getApps } from './apps'; import { syncSecrets } from './sync'; +import { revokeAccess } from './revoke'; export { exchangeCode, exchangeRefresh, getApps, - syncSecrets + syncSecrets, + revokeAccess } \ No newline at end of file diff --git a/backend/src/integrations/revoke.ts b/backend/src/integrations/revoke.ts new file mode 100644 index 000000000..833e6c88a --- /dev/null +++ b/backend/src/integrations/revoke.ts @@ -0,0 +1,50 @@ +import axios from 'axios'; +import * as Sentry from '@sentry/node'; +import { + IIntegrationAuth, + IntegrationAuth, + Integration +} from '../models'; +import { + INTEGRATION_HEROKU, + INTEGRATION_VERCEL, + INTEGRATION_NETLIFY +} from '../variables'; + +const revokeAccess = async ({ + integrationAuth, + accessToken +}: { + integrationAuth: IIntegrationAuth, + accessToken: String +}) => { + try { + // add any integration-specific revocation logic + switch (integrationAuth.integration) { + case INTEGRATION_HEROKU: + break; + case INTEGRATION_VERCEL: + break; + case INTEGRATION_NETLIFY: + break; + } + + const deletedIntegrationAuth = await IntegrationAuth.findOneAndDelete({ + _id: integrationAuth._id + }); + + if (deletedIntegrationAuth) { + await Integration.deleteMany({ + integrationAuth: deletedIntegrationAuth._id + }); + } + } catch (err) { + Sentry.setUser(null); + Sentry.captureException(err); + throw new Error('Failed to delete integration authorization'); + } +} + +export { + revokeAccess +} \ No newline at end of file diff --git a/frontend/components/integrations/CloudIntegration.tsx b/frontend/components/integrations/CloudIntegration.tsx index f3f1ad153..b9bbb08c8 100644 --- a/frontend/components/integrations/CloudIntegration.tsx +++ b/frontend/components/integrations/CloudIntegration.tsx @@ -1,5 +1,6 @@ import React from "react"; import Image from "next/image"; +import { useRouter } from "next/router"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { faCheck, @@ -33,6 +34,7 @@ const CloudIntegration = ({ integrationOptionPress, integrationAuths }: Props) => { + const router = useRouter(); return integrationAuths ? (
{ + onClick={(event) => { + event.stopPropagation(); deleteIntegrationAuth({ integrationAuthId: integrationAuths .filter( diff --git a/frontend/pages/integrations/[id].js b/frontend/pages/integrations/[id].js index f8a8e3de0..6ebf3888e 100644 --- a/frontend/pages/integrations/[id].js +++ b/frontend/pages/integrations/[id].js @@ -115,44 +115,24 @@ export default function Integrations() { * @returns */ const handleIntegrationOption = async ({ integrationOption }) => { - // TODO: modularize and handle switch by slug - - console.log('handle', integrationOption); - - // generate CSRF token for OAuth2 code-token exchange integrations - const state = crypto.randomBytes(16).toString("hex"); - localStorage.setItem('latestCSRFToken', state); - - switch (integrationOption.name) { - case 'Heroku': - // console.log('Heroku integration ', integrationOption); - window.location = `https://id.heroku.com/oauth/authorize?client_id=${integrationOption.clientId}&response_type=code&scope=write-protected&state=${state}`; - break; - case 'Vercel': - window.location = `https://vercel.com/integrations/infisical/new?state=${state}`; - break; - case 'Netlify': - // window.location = `https://app.netlify.com/authorize?client_id=${integrationOption.clientId}&response_type=token&redirect_uri=${integrationOption.redirectURL}&state=${state}`; - window.location = `https://app.netlify.com/authorize?client_id=${integrationOption.clientId}&response_type=code&redirect_uri=${integrationOption.redirectURL}&state=${state}`; - // const res = await axios.post('https://api.netlify.com/api/v1/oauth/tickets' + '?client_id=' + integrationOption.clientId); - - // window.location = `https://app.netlify.com/authorize?client_id=${integrationOption.clientId}&response_type=ticket&redirect_uri=${integrationOption.redirectURL}&state=${state}&ticket=${res.data.id}`; - // `https://app.netlify.com/authorize?response_type=ticket&ticket=${ticket.id}` - try { - // const res = await axios.post('https://api.netlify.com/api/v1/oauth/tickets' + '?client_id=' + integrationOption.clientId); - // console.log('res response', res); - // const res2 = await axios.get('https://api.netlify.com/api/v1/oauth/tickets/' + res.data.id); - // console.log('res2 response', res2); - // console.log('ticket_id', res.data.id); - // // exchange ticket: - // const res3 = await axios.get(`https://api.netlify.com/api/v1/oauth/tickets/${res.data.id}/exchange`); - // console.log('res3 response', res3); - - } catch (err) { - console.error('Netlify ', err); - } - - break; + try { + // generate CSRF token for OAuth2 code-token exchange integrations + const state = crypto.randomBytes(16).toString("hex"); + localStorage.setItem('latestCSRFToken', state); + + switch (integrationOption.name) { + case 'Heroku': + window.location = `https://id.heroku.com/oauth/authorize?client_id=${integrationOption.clientId}&response_type=code&scope=write-protected&state=${state}`; + break; + case 'Vercel': + window.location = `https://vercel.com/integrations/infisical/new?state=${state}`; + break; + case 'Netlify': + window.location = `https://app.netlify.com/authorize?client_id=${integrationOption.clientId}&response_type=code&redirect_uri=${integrationOption.redirectURL}&state=${state}`; + break; + } + } catch (err) { + console.log(err); } } From 36300cd19dfb3636a4397b2542bde7f21f4e1c9c Mon Sep 17 00:00:00 2001 From: Tuan Dang Date: Fri, 16 Dec 2022 15:44:50 -0500 Subject: [PATCH 20/25] Begin personal access token-based integrations --- .../dialog/IntegrationAccessTokenDialog.js | 100 ++++++++++++++++++ frontend/pages/integrations/[id].js | 21 +++- frontend/public/json/cloudIntegrations.json | 33 ++++-- 3 files changed, 143 insertions(+), 11 deletions(-) create mode 100644 frontend/components/basic/dialog/IntegrationAccessTokenDialog.js diff --git a/frontend/components/basic/dialog/IntegrationAccessTokenDialog.js b/frontend/components/basic/dialog/IntegrationAccessTokenDialog.js new file mode 100644 index 000000000..dca8d672b --- /dev/null +++ b/frontend/components/basic/dialog/IntegrationAccessTokenDialog.js @@ -0,0 +1,100 @@ +import { Fragment } from "react"; +import { Dialog, Transition } from "@headlessui/react"; +import getLatestFileKey from "../../../pages/api/workspace/getLatestFileKey"; +import setBotActiveStatus from "../../../pages/api/bot/setBotActiveStatus"; +import { + decryptAssymmetric, + encryptAssymmetric +} from "../../utilities/cryptography/crypto"; +import Button from "../buttons/Button"; +import InputField from "../InputField"; + +const IntegrationAccessTokenDialog = ({ + isOpen, + closeModal, + selectedIntegrationOption, + handleBotActivate, + handleIntegrationOption +}) => { + + const submit = async () => { + try { + // 1. activate bot + await handleBotActivate(); + + // 2. start integration + await handleIntegrationOption({ + integrationOption: selectedIntegrationOption + }); + } catch (err) { + console.log(err); + } + + closeModal(); + } + + return ( +
+ + + +
+ +
+
+ + + + Grant Infisical access to your secrets + +
+

+ Most cloud integrations require Infisical to be able to decrypt your secrets so they can be forwarded over. +

+
+
+ {/*
+
+
+
+
+
+
+
+ ); +} + +export default IntegrationAccessTokenDialog; \ No newline at end of file diff --git a/frontend/pages/integrations/[id].js b/frontend/pages/integrations/[id].js index 6ebf3888e..46aec2220 100644 --- a/frontend/pages/integrations/[id].js +++ b/frontend/pages/integrations/[id].js @@ -15,6 +15,7 @@ import getBot from "../api/bot/getBot"; import setBotActiveStatus from "../api/bot/setBotActiveStatus"; import getLatestFileKey from "../api/workspace/getLatestFileKey"; import ActivateBotDialog from "~/components/basic/dialog/ActivateBotDialog"; +import IntegrationAccessTokenDialog from "~/components/basic/dialog/IntegrationAccessTokenDialog"; const { decryptAssymmetric, encryptAssymmetric @@ -27,7 +28,8 @@ export default function Integrations() { const [integrationAuths, setIntegrationAuths] = useState([]); const [integrations, setIntegrations] = useState([]); const [bot, setBot] = useState(null); - const [isActivateBotOpen, setIsActivateBotOpen] = useState(false); + const [isActivateBotDialogOpen, setIsActivateBotDialogOpen] = useState(false); + const [isIntegrationAccessTokenDialogOpen, setIntegrationAccessTokenDialogOpen] = useState(true); const [selectedIntegrationOption, setSelectedIntegrationOption] = useState(null); const router = useRouter(); @@ -120,6 +122,8 @@ export default function Integrations() { const state = crypto.randomBytes(16).toString("hex"); localStorage.setItem('latestCSRFToken', state); + // TODO: Add CircleCI, Render, Fly.io + switch (integrationOption.name) { case 'Heroku': window.location = `https://id.heroku.com/oauth/authorize?client_id=${integrationOption.clientId}&response_type=code&scope=write-protected&state=${state}`; @@ -130,6 +134,10 @@ export default function Integrations() { case 'Netlify': window.location = `https://app.netlify.com/authorize?client_id=${integrationOption.clientId}&response_type=code&redirect_uri=${integrationOption.redirectURL}&state=${state}`; break; + case 'Fly.io': + console.log('fly.io'); + setIntegrationAccessTokenDialogOpen(true); + break; } } catch (err) { console.log(err); @@ -179,8 +187,15 @@ export default function Integrations() { isProjectRelated={true} /> setIsActivateBotOpen(false)} + isOpen={isActivateBotDialogOpen} + closeModal={() => setIsActivateBotDialogOpen(false)} + selectedIntegrationOption={selectedIntegrationOption} + handleBotActivate={handleBotActivate} + handleIntegrationOption={handleIntegrationOption} + /> + setIntegrationAccessTokenDialogOpen(false)} selectedIntegrationOption={selectedIntegrationOption} handleBotActivate={handleBotActivate} handleIntegrationOption={handleIntegrationOption} diff --git a/frontend/public/json/cloudIntegrations.json b/frontend/public/json/cloudIntegrations.json index aa988fb5f..26ff79fc5 100644 --- a/frontend/public/json/cloudIntegrations.json +++ b/frontend/public/json/cloudIntegrations.json @@ -5,7 +5,8 @@ "image": "Heroku", "isAvailable": true, "type": "oauth2", - "clientId": "7b1311a1-1cb2-4938-8adf-f37a399ec41b" + "clientId": "7b1311a1-1cb2-4938-8adf-f37a399ec41b", + "docsLink": "" }, { "name": "Vercel", @@ -13,7 +14,8 @@ "image": "Vercel", "isAvailable": true, "type": "vercel", - "clientId": "" + "clientId": "", + "docsLink": "" }, { "name": "Netlify", @@ -22,7 +24,17 @@ "isAvailable": true, "type": "oauth2", "clientId": "fYWBhD3gt0pT62UIwYrlGRcy--pPVLYIQad6ORrES9o", - "redirectURL": "http://localhost:8080/netlify" + "redirectURL": "http://localhost:8080/netlify", + "docsLink": "" + }, + { + "name": "Fly.io", + "slug": "flyio", + "image": "Google Cloud Platform", + "isAvailable": true, + "type": "accessToken", + "clientId": "", + "docsLink": "" }, { "name": "Google Cloud Platform", @@ -30,7 +42,8 @@ "image": "Google Cloud Platform", "isAvailable": false, "type": "", - "clientId": "" + "clientId": "", + "docsLink": "" }, { "name": "Amazon Web Services", @@ -38,7 +51,8 @@ "image": "Amazon Web Services", "isAvailable": false, "type": "", - "clientId": "" + "clientId": "", + "docsLink": "" }, { "name": "Microsoft Azure", @@ -46,7 +60,8 @@ "image": "Microsoft Azure", "isAvailable": false, "type": "", - "clientId": "" + "clientId": "", + "docsLink": "" }, { "name": "Travis CI", @@ -54,7 +69,8 @@ "image": "Travis CI", "isAvailable": false, "type": "", - "clientId": "" + "clientId": "", + "docsLink": "" }, { "name": "Circle CI", @@ -62,6 +78,7 @@ "image": "Circle CI", "isAvailable": false, "type": "", - "clientId": "" + "clientId": "", + "docsLink": "" } ] \ No newline at end of file From 547555591b72430b8d9eb336ce32dfed464ce46d Mon Sep 17 00:00:00 2001 From: Tuan Dang Date: Sun, 18 Dec 2022 12:18:50 -0500 Subject: [PATCH 21/25] Refactor integrations logic and replace hardcoded client ids with envars --- .env.example | 7 +- backend/environment.d.ts | 8 +- backend/src/config/index.ts | 4 +- backend/src/integrations/exchange.ts | 4 +- backend/src/integrations/refresh.ts | 4 +- .../requireIntegrationAuthorizationAuth.ts | 14 +-- backend/src/routes/integrationAuth.ts | 3 +- docker-compose.dev.yml | 4 + docker-compose.yml | 3 + docs/self-hosting/configuration/envars.mdx | 7 +- .../integrations/CloudIntegration.tsx | 1 + .../components/integrations/Integration.tsx | 2 - frontend/components/utilities/config/index.ts | 8 +- frontend/pages/integrations/[id].js | 25 +++--- frontend/public/data/cloudIntegrations.js | 83 ++++++++++++++++++ .../public/images/integrations/Vercel.png | Bin 0 -> 5758 bytes frontend/public/json/cloudIntegrations.json | 9 -- 17 files changed, 144 insertions(+), 42 deletions(-) create mode 100644 frontend/public/data/cloudIntegrations.js create mode 100644 frontend/public/images/integrations/Vercel.png diff --git a/.env.example b/.env.example index b662b7961..989a285e3 100644 --- a/.env.example +++ b/.env.example @@ -47,7 +47,12 @@ SMTP_PASSWORD= # Integration # Optional only if integration is used -OAUTH_CLIENT_SECRET_HEROKU= +CLIENT_ID_HEROKU= +CLIENT_ID_VERCEL= +CLIENT_ID_NETLIFY= +CLIENT_SECRET_HEROKU= +CLIENT_SECRET_VERCEL= +CLIENT_SECRET_NETLIFY= # Sentry (optional) for monitoring errors SENTRY_DSN= diff --git a/backend/environment.d.ts b/backend/environment.d.ts index 33827fb2b..853f52e5b 100644 --- a/backend/environment.d.ts +++ b/backend/environment.d.ts @@ -14,8 +14,12 @@ declare global { JWT_SIGNUP_SECRET: string; MONGO_URL: string; NODE_ENV: 'development' | 'staging' | 'testing' | 'production'; - OAUTH_CLIENT_SECRET_HEROKU: string; - OAUTH_TOKEN_URL_HEROKU: string; + CLIENT_ID_HEROKU: string; + CLIENT_ID_VERCEL: string; + CLIENT_ID_NETLIFY: string; + CLIENT_SECRET_HEROKU: string; + CLIENT_SECRET_VERCEL: string; + CLIENT_SECRET_NETLIFY: string; POSTHOG_HOST: string; POSTHOG_PROJECT_API_KEY: string; PRIVATE_KEY: string; diff --git a/backend/src/config/index.ts b/backend/src/config/index.ts index ea5f653a3..7c23bee4a 100644 --- a/backend/src/config/index.ts +++ b/backend/src/config/index.ts @@ -10,7 +10,7 @@ const JWT_SIGNUP_LIFETIME = process.env.JWT_SIGNUP_LIFETIME! || '15m'; const JWT_SIGNUP_SECRET = process.env.JWT_SIGNUP_SECRET!; const MONGO_URL = process.env.MONGO_URL!; const NODE_ENV = process.env.NODE_ENV! || 'production'; -const OAUTH_CLIENT_SECRET_HEROKU = process.env.OAUTH_CLIENT_SECRET_HEROKU!; +const CLIENT_SECRET_HEROKU = process.env.CLIENT_SECRET_HEROKU!; const CLIENT_ID_VERCEL = process.env.CLIENT_ID_VERCEL!; const CLIENT_ID_NETLIFY = process.env.CLIENT_ID_NETLIFY!; const CLIENT_SECRET_VERCEL = process.env.CLIENT_SECRET_VERCEL!; @@ -49,9 +49,9 @@ export { JWT_SIGNUP_SECRET, MONGO_URL, NODE_ENV, - OAUTH_CLIENT_SECRET_HEROKU, CLIENT_ID_VERCEL, CLIENT_ID_NETLIFY, + CLIENT_SECRET_HEROKU, CLIENT_SECRET_VERCEL, CLIENT_SECRET_NETLIFY, POSTHOG_HOST, diff --git a/backend/src/integrations/exchange.ts b/backend/src/integrations/exchange.ts index 5b7bc71ae..26e6521a1 100644 --- a/backend/src/integrations/exchange.ts +++ b/backend/src/integrations/exchange.ts @@ -11,7 +11,7 @@ import { } from '../variables'; import { SITE_URL, - OAUTH_CLIENT_SECRET_HEROKU, + CLIENT_SECRET_HEROKU, CLIENT_ID_VERCEL, CLIENT_ID_NETLIFY, CLIENT_SECRET_VERCEL, @@ -114,7 +114,7 @@ const exchangeCodeHeroku = async ({ new URLSearchParams({ grant_type: 'authorization_code', code: code, - client_secret: OAUTH_CLIENT_SECRET_HEROKU + client_secret: CLIENT_SECRET_HEROKU } as any) )).data; diff --git a/backend/src/integrations/refresh.ts b/backend/src/integrations/refresh.ts index b19a6a663..16870944d 100644 --- a/backend/src/integrations/refresh.ts +++ b/backend/src/integrations/refresh.ts @@ -2,7 +2,7 @@ import axios from 'axios'; import * as Sentry from '@sentry/node'; import { INTEGRATION_HEROKU } from '../variables'; import { - OAUTH_CLIENT_SECRET_HEROKU + CLIENT_SECRET_HEROKU } from '../config'; import { INTEGRATION_HEROKU_TOKEN_URL @@ -59,7 +59,7 @@ const exchangeRefreshHeroku = async ({ new URLSearchParams({ grant_type: 'refresh_token', refresh_token: refreshToken, - client_secret: OAUTH_CLIENT_SECRET_HEROKU + client_secret: CLIENT_SECRET_HEROKU } as any) ); diff --git a/backend/src/middleware/requireIntegrationAuthorizationAuth.ts b/backend/src/middleware/requireIntegrationAuthorizationAuth.ts index 0bf552654..ed44ffec5 100644 --- a/backend/src/middleware/requireIntegrationAuthorizationAuth.ts +++ b/backend/src/middleware/requireIntegrationAuthorizationAuth.ts @@ -10,14 +10,16 @@ import { validateMembership } from '../helpers/membership'; * @param {Object} obj * @param {String[]} obj.acceptedRoles - accepted workspace roles * @param {String[]} obj.acceptedStatuses - accepted workspace statuses - * @param {Boolean} obj.attachRefresh - whether or not to decrypt and attach integration authorization refresh token onto request + * @param {Boolean} obj.attachAccessToken - whether or not to decrypt and attach integration authorization access token onto request */ const requireIntegrationAuthorizationAuth = ({ acceptedRoles, - acceptedStatuses + acceptedStatuses, + attachAccessToken = true }: { acceptedRoles: string[]; acceptedStatuses: string[]; + attachAccessToken?: boolean; }) => { return async (req: Request, res: Response, next: NextFunction) => { try { @@ -41,9 +43,11 @@ const requireIntegrationAuthorizationAuth = ({ }); req.integrationAuth = integrationAuth; - req.accessToken = await IntegrationService.getIntegrationAuthAccess({ - integrationAuthId: integrationAuth._id.toString() - }); + if (attachAccessToken) { + req.accessToken = await IntegrationService.getIntegrationAuthAccess({ + integrationAuthId: integrationAuth._id.toString() + }); + } return next(); } catch (err) { diff --git a/backend/src/routes/integrationAuth.ts b/backend/src/routes/integrationAuth.ts index 650221f82..159d31934 100644 --- a/backend/src/routes/integrationAuth.ts +++ b/backend/src/routes/integrationAuth.ts @@ -42,7 +42,8 @@ router.delete( requireAuth, requireIntegrationAuthorizationAuth({ acceptedRoles: [ADMIN, MEMBER], - acceptedStatuses: [GRANTED] + acceptedStatuses: [GRANTED], + attachAccessToken: false }), param('integrationAuthId'), validateRequest, diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 623462d5b..4d6c3fe86 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -51,8 +51,12 @@ services: env_file: .env environment: - NEXT_PUBLIC_ENV=development + - INFISICAL_TELEMETRY_ENABLED=${TELEMETRY_ENABLED} + - NEXT_PUBLIC_SITE_URL=${SITE_URL} - NEXT_PUBLIC_STRIPE_PRODUCT_PRO=${STRIPE_PRODUCT_PRO} - NEXT_PUBLIC_STRIPE_PRODUCT_STARTER=${STRIPE_PRODUCT_STARTER} + - NEXT_PUBLIC_CLIENT_ID_HEROKU=${CLIENT_ID_HEROKU} + - NEXT_PUBLIC_CLIENT_ID_NETLIFY=${CLIENT_ID_NETLIFY} networks: - infisical-dev diff --git a/docker-compose.yml b/docker-compose.yml index bd9022cef..366b512db 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -41,6 +41,9 @@ services: - INFISICAL_TELEMETRY_ENABLED=${TELEMETRY_ENABLED} - NEXT_PUBLIC_STRIPE_PRODUCT_PRO=${STRIPE_PRODUCT_PRO} - NEXT_PUBLIC_STRIPE_PRODUCT_STARTER=${STRIPE_PRODUCT_STARTER} + - NEXT_PUBLIC_SITE_URL=${SITE_URL} + - NEXT_PUBLIC_CLIENT_ID_HEROKU=${CLIENT_ID_HEROKU} + - NEXT_PUBLIC_CLIENT_ID_NETLIFY=${CLIENT_ID_NETLIFY} networks: - infisical diff --git a/docs/self-hosting/configuration/envars.mdx b/docs/self-hosting/configuration/envars.mdx index a55efbebc..9c6697df5 100644 --- a/docs/self-hosting/configuration/envars.mdx +++ b/docs/self-hosting/configuration/envars.mdx @@ -28,6 +28,9 @@ Configuring Infisical requires setting some environment variables. There is a fi | `SMTP_USERNAME` | ❗️ Credential to connect to host (e.g. `team@infisical.com`) | `None` | | `SMTP_PASSWORD` | ❗️ Credential to connect to host | `None` | | `TELEMETRY_ENABLED` | `true` or `false`. [More](../overview). | `true` | -| `OAUTH_CLIENT_SECRET_HEROKU` | OAuth client secret for Heroku integration | `None` | -| `OAUTH_TOKEN_URL_HEROKU` | OAuth token URL for Heroku integration | `None` | +| `CLIENT_ID_VERCEL` | OAuth client id for Vercel integration | `None` | +| `CLIENT_ID_NETLIFY` | OAuth client id for Netlify integration | `None` | +| `CLIENT_SECRET_HEROKU` | OAuth client secret for Heroku integration | `None` | +| `CLIENT_SECRET_VERCEL` | OAuth client secret for Vercel integration | `None` | +| `CLIENT_SECRET_NETLIFY` | OAuth client secret for Netlify integration | `None` | | `SENTRY_DSN` | DSN for error-monitoring with Sentry | `None` | diff --git a/frontend/components/integrations/CloudIntegration.tsx b/frontend/components/integrations/CloudIntegration.tsx index b9bbb08c8..f64268dba 100644 --- a/frontend/components/integrations/CloudIntegration.tsx +++ b/frontend/components/integrations/CloudIntegration.tsx @@ -87,6 +87,7 @@ const CloudIntegration = ({ ) .map((authorization) => authorization._id)[0], }); + router.reload(); }} className="cursor-pointer w-max bg-red py-0.5 px-2 rounded-b-md text-xs flex flex-row items-center opacity-0 group-hover:opacity-100 duration-200" diff --git a/frontend/components/integrations/Integration.tsx b/frontend/components/integrations/Integration.tsx index 8a323336e..d3fe58d65 100644 --- a/frontend/components/integrations/Integration.tsx +++ b/frontend/components/integrations/Integration.tsx @@ -17,8 +17,6 @@ import getIntegrationApps from "../../pages/api/integrations/GetIntegrationApps" import Button from "~/components/basic/buttons/Button"; import ListBox from "~/components/basic/Listbox"; -// TODO: optimize laggy dropdown for app options - interface Integration { app?: string; environment: string; diff --git a/frontend/components/utilities/config/index.ts b/frontend/components/utilities/config/index.ts index d0ffed00c..256c350a8 100644 --- a/frontend/components/utilities/config/index.ts +++ b/frontend/components/utilities/config/index.ts @@ -4,6 +4,9 @@ const POSTHOG_HOST = process.env.NEXT_PUBLIC_POSTHOG_HOST! || "https://app.posthog.com"; const STRIPE_PRODUCT_PRO = process.env.NEXT_PUBLIC_STRIPE_PRODUCT_PRO!; const STRIPE_PRODUCT_STARTER = process.env.NEXT_PUBLIC_STRIPE_PRODUCT_STARTER!; +const SITE_URL = process.env.NEXT_PUBLIC_SITE_URL!; +const CLIENT_ID_HEROKU = process.env.NEXT_PUBLIC_CLIENT_ID_HEROKU!; +const CLIENT_ID_NETLIFY = process.env.NEXT_PUBLIC_CLIENT_ID_NETLIFY!; export { ENV, @@ -11,4 +14,7 @@ export { POSTHOG_HOST, STRIPE_PRODUCT_PRO, STRIPE_PRODUCT_STARTER, -}; + SITE_URL, + CLIENT_ID_HEROKU, + CLIENT_ID_NETLIFY +}; \ No newline at end of file diff --git a/frontend/pages/integrations/[id].js b/frontend/pages/integrations/[id].js index 46aec2220..335fc4d40 100644 --- a/frontend/pages/integrations/[id].js +++ b/frontend/pages/integrations/[id].js @@ -8,7 +8,8 @@ import FrameworkIntegrationSection from "~/components/integrations/FrameworkInte import CloudIntegrationSection from "~/components/integrations/CloudIntegrationSection"; import IntegrationSection from "~/components/integrations/IntegrationSection"; import frameworkIntegrationOptions from "../../public/json/frameworkIntegrations.json"; -import cloudIntegrationOptions from "../../public/json/cloudIntegrations.json"; +// import cloudIntegrationOptions from "../../public/json/cloudIntegrations.json"; +import { cloudIntegrationOptions } from "../../public/data/cloudIntegrations"; import getWorkspaceAuthorizations from "../api/integrations/getWorkspaceAuthorizations"; import getWorkspaceIntegrations from "../api/integrations/getWorkspaceIntegrations"; import getBot from "../api/bot/getBot"; @@ -29,7 +30,7 @@ export default function Integrations() { const [integrations, setIntegrations] = useState([]); const [bot, setBot] = useState(null); const [isActivateBotDialogOpen, setIsActivateBotDialogOpen] = useState(false); - const [isIntegrationAccessTokenDialogOpen, setIntegrationAccessTokenDialogOpen] = useState(true); + // const [isIntegrationAccessTokenDialogOpen, setIntegrationAccessTokenDialogOpen] = useState(true); const [selectedIntegrationOption, setSelectedIntegrationOption] = useState(null); const router = useRouter(); @@ -122,22 +123,20 @@ export default function Integrations() { const state = crypto.randomBytes(16).toString("hex"); localStorage.setItem('latestCSRFToken', state); - // TODO: Add CircleCI, Render, Fly.io - switch (integrationOption.name) { case 'Heroku': window.location = `https://id.heroku.com/oauth/authorize?client_id=${integrationOption.clientId}&response_type=code&scope=write-protected&state=${state}`; break; case 'Vercel': - window.location = `https://vercel.com/integrations/infisical/new?state=${state}`; + window.location = `https://vercel.com/integrations/infisical-dev/new?state=${state}`; break; case 'Netlify': - window.location = `https://app.netlify.com/authorize?client_id=${integrationOption.clientId}&response_type=code&redirect_uri=${integrationOption.redirectURL}&state=${state}`; - break; - case 'Fly.io': - console.log('fly.io'); - setIntegrationAccessTokenDialogOpen(true); + window.location = `https://app.netlify.com/authorize?client_id=${integrationOption.clientId}&response_type=code&state=${state}&redirect_uri=${integrationOption.redirectURL}`; break; + // case 'Fly.io': + // console.log('fly.io'); + // setIntegrationAccessTokenDialogOpen(true); + // break; } } catch (err) { console.log(err); @@ -163,7 +162,7 @@ export default function Integrations() { } // case: bot is not active -> open modal to activate bot - setIsActivateBotOpen(true); + setIsActivateBotDialogOpen(true); } catch (err) { console.error(err); } @@ -193,13 +192,13 @@ export default function Integrations() { handleBotActivate={handleBotActivate} handleIntegrationOption={handleIntegrationOption} /> - setIntegrationAccessTokenDialogOpen(false)} selectedIntegrationOption={selectedIntegrationOption} handleBotActivate={handleBotActivate} handleIntegrationOption={handleIntegrationOption} - /> + /> */} ((q}>zjEqlV;k`v(**xa%w;1dA1-BZjh zQT#GUIS?f%MhQ_81-nBO$Qog6K!T02+2`MMnVE4_b#+bmOusxosWs#79)s=r*YjG{ z>jV=E)oQie-Q8VTSy`#9Teq%4r*=Pm`m{{%%k;h+oD(>QuQlIhf$m!f?(^g;o%EM7+rpL#}Yl3ltg%Cn8 z@Q=~JKY%aAvq7I`AdDLQe=jdDFHe)@@B|YI3!w=N{PpYCW3V43mjnW+h-@=WKLf;Z zX<}kxOfX7V2x4{c57XPDqBCwzI~k)m<`Nw>wieGRtPtv4p5x+~%Tq*ZB#4W2j1@ij zE}RgChK8!**~~vIVxIXQKYlFk-@h-OJbB{3$3Yn$|LaGO9{Km+Jq{IQl~xZD>FVo- z4IBKUr>Dn1w`tQR5i`h0jgc#Ov8VxeA%xK6`cy@WU~u2Nch4W_4<9}h^Yiom`*yN% z<3?W?5Jhiqum3ef(<&qekAm#elarGdM1jb)5CQ?5q_-c77G{48^0~RWjtzQSU^O6& zZQHi_udP@P2m)~gL{WrTB9}rS0aOA33=0PVV8*|5=Z;udSnvg)b7j&FgaK;-Vc^x( zVvyT(A@737l@K6+uCA^Tn$3nqL@@Yo-@fe&0B%nnff#n|*x|pzt%^#X1(6FOxVk_X z{BU<>W@d6b_;tWa;Mloyr-(`(1(A6nP!?!JL=?b-2M_$2|K`n`nRR>OLb^|T_Uwsd zB@jc9AkI;i>8!{BnH2)kp!CN$AEok(S-*(0zbz2Nu3fvt_U+rPKNAGir?OtP&YTe7 z22mVv9_Z)ya14+v5XAod`~6rVjI3FQ z)CwU;iOP(S5%lr={PWL#CWuKM)FL8ena)yX?sVFl`cf$bxI$C~owCUY{r1~$;>L{| zg-?UF8#Mp*_4WCJ2m{uEYBuks*;PuN5XcoePQN(hrj4!;Y5~`;U-t`QOy_{RbnxIo z8|$!uG;3O2r7n>oP#gT7ok&yM&~YKy#seG+WCO$wnDr4qm`GdP&{5aOMg(Y# zg8<+Tk&h{ubc4Uq-Me@FzuV@`n*~{V-L3Efj*(z4d7jsC%g`~4Kq45R*<(r=f?{@s zvW<4*?)M1Z0gM8K|4wt*N995@iRS`It>z1K-GwB0pAdX!A! zzlA{rhzGv=?mKpcasVc>-+%wzj~(DDX+W0n!~Xt$Z+3R}n&<#+6+%!WxF8JaQN#kj z{PK%n@&)1sU`nwfYuG6tjpBtLMNG785#(0vy6g0xWf3ruk-L=Zz+P8z7Ta1D0z{y5i4YuP$7zOfA!PiN zfg8)`TfJU`nR zCm~Q=a7K3xE)l~dfvFZ1?3nIMlOWMo8CDi!}29UT>n8zHS1)>a`@2vkL`s?Kw4;0^{q z+#<=zlP5*F9Q*3SLVz)-ixotKx`I&%XtOre4H!41HIP6AXU?1vj2nj3J*>j8u)rt; zom+%FE?WaPnyrC^17~-TK{C`8jtUF3LO?g4&Khtlg*#JIQ`&2=6pR}N52b3;nW_~6 ziQu^EJo-Eg>SGLG)~|Pont?@7Sqml{j7eRacGzQ!{d-g=gXbR4(l=NWXz+6k+<*XJ z4NO7+3kwT=Iu)->G_Wls4h5m9RHa3rOwlow2mo6HH;x}a9x4KW!1?2ko%+TK4_tQ5 zHuGhSY13zQLQuLzoHBJIYOSC-tHjuW&Q#qF>$)+$dcFBX(4$dR-OP(_+;9nk&!10x zC-hum3Ytwdg*8IZ70?hQvk;<#;MXMpM&Qip)ab|u7OF7S6xIlVVuPc)e}IWjD=AU7 z3T_yZIog_nN(jdyese}ctG+(>A-Q4jTn2RRQG*b$O^fO#1R-n@qM@rGM}_;4qGNE6 z&~h=Ly3lM9*e!~QxH3Q7hvbIND(F0(QqxUJwam@U3C5ndtbv64kfLKKNGd1$RUrgv zQ>75tB1FT~Pvi>sAw@$sd82<$6(_6;p{uJ)DFXCwa1qU(uzn&}xDP2Bh--D@R#j!L zca5s54;l8GWSW2glAO5_D{$a!kIpKnCjC_*P<-&N>O)*av&Xid$QABGiiWP;g50Au zV|}&zbZBVksp>;te)%Q4MixK-axs3=pgWbHRUOWT4I9Ma!-tjE$aU(i??&}4f^Ol0 z$dvqC3a*wL@UzdWDl_=kY zX`~aWO|}Yl=sX&Vi7WMAmx7ZAR5!Cl2&YqP2e=Q(4nx4)Fy^I2P`XAOADD@|f*cj@ zLkeepeqMK}Va%6MWz#LoWQ$;?(<{li4=EhOWL~bXFvB8LRClom%yilsNVpFv9Gw{I zGe;$=vQ<sA?dM6p_X(t1hxojlI|yRh5L~7=!8J7(du(*;Tr8$-NZ4% zTGF1o=lzh>=-Mo3?Dxv|+PzO%2y4kgP|6kOwHU^7gGH!B{BR*@cR!IU+=sLlotxhr zEGyBOlcJKP6eU}Q=#(g6P^KE#B0MK+zBl(FHKH5Klqh;$S=O~zun?Y+mI`uIxDV+$ zhOykxV!;^5Y9@yJkfOmtFpyPiAmKhFdsqkt($r7n3ilz|!9viGwtga4xDUw=7J`Db z_7l0neMsS;*GRcZKand%??W<-<>%YE5G;f`@|uG$avzdzEK}*AabF|m zIe88)i&}BEnbhGLd8!LNJw2jmWTl_Tm7?|`b$53w&)1KAjuGl8LaMQdR>8_5(6Q+H z;XF|kAG(NrNLCg>)J4wq6v;KpdIdQuMe9StX22>aSpHSMN7J1b<->WTXc?8F^&uI? z^7E{bE?Cx?le|$hYal67ACk_yX>g5}mzO=&O`A4t5=9^n{Y0)5r4LCL?@|rTTYXQ5 zr@E;Ku2Ek4iCig4AChiTUs_u7~=2;AR>nCz0?|n!cH*VBjT3GYM9157H zRd@FG_KLicw|*j5^4^DJ$bL4AdATk8HPxLjz4VgEBfO@!kvwP3bZ!w%{taVZS_DsZ zb6#B|c8S)K*NU^wt7*=b`a*c}dBN^OTf46*Grjdid2WW$eZ{_-o1N*VbThM=FTfi)H(Xt$`TZr-zJk6@TIV@UPabRq!CEgvaPxTrdh0!5KG2WHX@ifB7`?o?hQ zx9Fnjvq~YL%B;&C_4V}$hDjra%;thAJ^)G~1n!YiL0PRSF!^Wi-o486G^vl(uTNH? z-3?n;T*E?#Jg;SDvI)kdVMn`*VV~CuAy~LjQ(fbrnMoVFcI{H0$CAwEMK~(Lo{ymI zJz}fCqy<<4oy$%_I4vwN3W4&j7j%0bWv#%*OUtGmWlf$0=+Sp#4t1SpFXrnL&3L1NlXoYnwXKVFs;Hp;OAlNR>x->_lV;KCJGP%Ql&4v@PhI> z3TKoh`b5M?%N9WrH061!Yxu!*+wd_=Gia#ORgW>omX?-AMa;w|1hij}7~a#x3uuyh z{q@%c!Ws( zz58<#!UuFHD5t^}y8DJF< zH#~m)STNZIlN$QWOuHd=A|`02BSHXy7(UhQh5;agZ@&44#gI{u85$TEFm4o`1`&Xc z2>}GD)hUx05I|F9Efa_dOq;&7J4I@utuaC~Xwsu>(j~+UIyVXH84yI{ z7mAD$uW3j(_&cFz2qM7WPDh$V06J*r#h7$4I_0z05(CiL^T|Y4~F)=1GKq@UlJ!s1aQmoi6X25iBPfyS4{`qL@W_Kwb@cTrI1p0*VJGP)k zuF&77rlziljF5VZP)`)=0I-)5VhFYnZpclx3=i~f09JthHpmJkwzfDaG9?5M1k$7Q z3qNlA85Tq|;QF9l+ExG{jkSR&WKIZ@!NEZk-_F=t2XN=k9Y4dwZQg34!~haxgz(@P zvH~@cVKOTO3Asxj(;wnw6hXi;+_-VW7evvm1k#|0tB?tbvVJ^pgU+R~IOam;g#cXK zB?;UnSPC3PLeBme{865UDg}TitY*~>QjrTGNLmpDEW`Z#yuWTbudb7P9yWY`mq!8P zc?8&sJ;fVGgq zX!pKM^@AQoF1gk_25NL%qMz|X5d?(XbBJsK{zI_H~6gaG(bY z=Wx`&SAl^n2QvmY-gkF*`-2__-XpkeWu{jl-5>gWjAcCy>Pq2+Ai-jwQmJ%|4#W{v zQA}zy_%D(`$BH!jt0=4x>Om|)f~eA&I46qpQiu4O(nr8mXWZ|aS<*|o_-~a+{dKsVr zSAo@_*MYbL?=YmBL$B8`>(gvsqthM_d^cDKF@fNlUSK^cL5j9agLJn{*UDFNQ_4Eg wU~tn+z7V|kf@}_sMGW>}$)hKDz{k%20gAaWf|RxmfB*mh07*qoM6N<$g0^&3dH?_b literal 0 HcmV?d00001 diff --git a/frontend/public/json/cloudIntegrations.json b/frontend/public/json/cloudIntegrations.json index 26ff79fc5..e932879d8 100644 --- a/frontend/public/json/cloudIntegrations.json +++ b/frontend/public/json/cloudIntegrations.json @@ -27,15 +27,6 @@ "redirectURL": "http://localhost:8080/netlify", "docsLink": "" }, - { - "name": "Fly.io", - "slug": "flyio", - "image": "Google Cloud Platform", - "isAvailable": true, - "type": "accessToken", - "clientId": "", - "docsLink": "" - }, { "name": "Google Cloud Platform", "slug": "gcp", From 4dac03ab94bfb3e57328892f3f68e60e365ebf73 Mon Sep 17 00:00:00 2001 From: Tuan Dang Date: Sun, 18 Dec 2022 14:09:04 -0500 Subject: [PATCH 22/25] Patch undefined siteId passthrough to API --- frontend/components/integrations/Integration.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/frontend/components/integrations/Integration.tsx b/frontend/components/integrations/Integration.tsx index d3fe58d65..a42fa5bcf 100644 --- a/frontend/components/integrations/Integration.tsx +++ b/frontend/components/integrations/Integration.tsx @@ -172,6 +172,7 @@ const Integration = ({