diff --git a/.env.example b/.env.example index 3871841c0..989a285e3 100644 --- a/.env.example +++ b/.env.example @@ -47,8 +47,12 @@ SMTP_PASSWORD= # Integration # Optional only if integration is used -OAUTH_CLIENT_SECRET_HEROKU= -OAUTH_TOKEN_URL_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 5575ceb44..09d384771 100644 --- a/backend/src/config/index.ts +++ b/backend/src/config/index.ts @@ -10,8 +10,12 @@ 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 OAUTH_TOKEN_URL_HEROKU = process.env.OAUTH_TOKEN_URL_HEROKU!; +const CLIENT_SECRET_HEROKU = process.env.CLIENT_SECRET_HEROKU!; +const CLIENT_ID_HEROKU = process.env.CLIENT_ID_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!; +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! || @@ -46,8 +50,12 @@ export { JWT_SIGNUP_SECRET, MONGO_URL, NODE_ENV, - OAUTH_CLIENT_SECRET_HEROKU, - OAUTH_TOKEN_URL_HEROKU, + CLIENT_ID_HEROKU, + CLIENT_ID_VERCEL, + CLIENT_ID_NETLIFY, + CLIENT_SECRET_HEROKU, + CLIENT_SECRET_VERCEL, + CLIENT_SECRET_NETLIFY, POSTHOG_HOST, POSTHOG_PROJECT_API_KEY, PRIVATE_KEY, diff --git a/backend/src/controllers/botController.ts b/backend/src/controllers/botController.ts new file mode 100644 index 000000000..7819e32df --- /dev/null +++ b/backend/src/controllers/botController.ts @@ -0,0 +1,107 @@ +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.params; + + 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 + 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, + bot: req.bot._id, + workspace: req.bot.workspace + }, { + upsert: true, + new: true + }); + } 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..c242c239a 100644 --- a/backend/src/controllers/integrationAuthController.ts +++ b/backend/src/controllers/integrationAuthController.ts @@ -3,69 +3,45 @@ 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 { INTEGRATION_SET, INTEGRATION_OPTIONS, ENV_DEV } from '../variables'; +import { IntegrationService } from '../services'; +import { getApps, revokeAccess } from '../integrations'; + +export const getIntegrationOptions = async ( + req: Request, + res: Response +) => { + return res.status(200).send({ + integrationOptions: INTEGRATION_OPTIONS + }); +} /** * 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 */ -export const integrationAuthOauthExchange = async ( +export const oAuthExchange = async ( req: Request, res: Response ) => { try { - 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 } - ); } 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' }); } @@ -75,26 +51,25 @@ export const integrationAuthOauthExchange = 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({ + integrationAuth: req.integrationAuth, + 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 @@ -108,46 +83,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/controllers/integrationController.ts b/backend/src/controllers/integrationController.ts index b75d9b74a..910c7e825 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; @@ -24,104 +22,58 @@ 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 - }); -}; - -/** - * Sync secrets [secrets] to integration with id [integrationId] - * @param req - * @param res - * @returns - */ -export const syncIntegration = async (req: Request, res: Response) => { - // TODO: unfinished - make more versatile to accomodate for other integrations - 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 * @param res * @returns */ -export const modifyIntegration = async (req: Request, res: Response) => { +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 { update } = 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 }, - update, + { + environment, + isActive, + app, + target, + context, + siteId + }, { 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' }); } @@ -131,7 +83,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 @@ -144,6 +97,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/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..bfd9aee1f 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,8 @@ export const pushSecrets = async (req: Request, res: Response) => { workspaceId, keys }); - + + if (postHogClient) { postHogClient.capture({ event: 'secrets pushed', @@ -74,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); @@ -192,7 +201,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..8bb3a86c3 --- /dev/null +++ b/backend/src/events/secret.ts @@ -0,0 +1,37 @@ +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 + * @returns + */ +const eventPushSecrets = ({ + workspaceId, +}: { + workspaceId: string; +}) => { + return ({ + name: EVENT_PUSH_SECRETS, + workspaceId, + payload: { + + } + }); +} + +export { + eventPushSecrets +} diff --git a/backend/src/helpers/bot.ts b/backend/src/helpers/bot.ts new file mode 100644 index 000000000..3285ccd6f --- /dev/null +++ b/backend/src/helpers/bot.ts @@ -0,0 +1,230 @@ +import * as Sentry from '@sentry/node'; +import { + Bot, + BotKey, + Secret, + ISecret, + IUser +} from '../models'; +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] + * @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; +} + +/** + * 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, + environment, + 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, + 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..9aaff9741 100644 --- a/backend/src/helpers/integration.ts +++ b/backend/src/helpers/integration.ts @@ -0,0 +1,350 @@ +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, + INTEGRATION_VERCEL, + INTEGRATION_NETLIFY +} from '../variables'; + +interface Update { + workspace: string; + integration: string; + teamId?: string; + accountId?: string; +} + +/** + * 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 + }); + + let update: Update = { + workspace: workspaceId, + integration + } + + switch (integration) { + case INTEGRATION_VERCEL: + update.teamId = res.teamId; + break; + case INTEGRATION_NETLIFY: + update.accountId = res.accountId; + break; + } + + integrationAuth = await IntegrationAuth.findOneAndUpdate({ + workspace: workspaceId, + integration + }, update, { + new: true, + upsert: true + }); + + if (res.refreshToken) { + // case: refresh token returned from exchange + // set integration auth refresh token + await setIntegrationAuthRefreshHelper({ + integrationAuthId: integrationAuth._id.toString(), + refreshToken: res.refreshToken + }); + } + + 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({ + workspace: workspaceId, + environment: ENV_DEV, + isActive: false, + app: null, + integration, + integrationAuth: integrationAuth._id + }).save(); + } 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, + app: { $ne: null } + }); + + // 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 + }); + + 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.toString() + }); + + // sync secrets to integration + await syncSecrets({ + integration, + integrationAuth, + 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('workspace integration +accessCiphertext +accessIV +accessTag +accessExpiresAt + refreshCiphertext'); + + 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 17f101676..e69de29bb 100644 --- a/backend/src/helpers/integrationAuth.ts +++ b/backend/src/helpers/integrationAuth.ts @@ -1,174 +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'; - -/** - * 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; -}; - -/** - * 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, 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/workspace.ts b/backend/src/helpers/workspace.ts index 52d7d227b..b43252bf3 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 28e27cc9a..6abc65420 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -23,22 +23,23 @@ Sentry.init({ }); import { - signup as signupRouter, - auth as authRouter, - organization as organizationRouter, - workspace as workspaceRouter, - membershipOrg as membershipOrgRouter, - membership as membershipRouter, - key as keyRouter, - inviteOrg as inviteOrgRouter, - user as userRouter, - userAction as userActionRouter, - secret as secretRouter, - serviceToken as serviceTokenRouter, - password as passwordRouter, - stripe as stripeRouter, - integration as integrationRouter, - integrationAuth as integrationAuthRouter + signup as signupRouter, + auth as authRouter, + bot as botRouter, + organization as organizationRouter, + workspace as workspaceRouter, + membershipOrg as membershipOrgRouter, + membership as membershipRouter, + key as keyRouter, + inviteOrg as inviteOrgRouter, + user as userRouter, + userAction as userActionRouter, + secret as secretRouter, + serviceToken as serviceTokenRouter, + password as passwordRouter, + stripe as stripeRouter, + integration as integrationRouter, + integrationAuth as integrationAuthRouter } from './routes'; const connectWithRetry = () => { @@ -78,6 +79,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/apps.ts b/backend/src/integrations/apps.ts new file mode 100644 index 000000000..70680ef7d --- /dev/null +++ b/backend/src/integrations/apps.ts @@ -0,0 +1,169 @@ +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_NETLIFY_API_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 ({ + integrationAuth, + accessToken +}: { + integrationAuth: IIntegrationAuth; + accessToken: string; +}) => { + + interface App { + name: string; + siteId?: string; + } + + let apps: App[]; // TODO: add type and define payloads for apps + try { + switch (integrationAuth.integration) { + case INTEGRATION_HEROKU: + apps = await getAppsHeroku({ + accessToken + }); + break; + case INTEGRATION_VERCEL: + apps = await getAppsVercel({ + accessToken + }); + break; + case INTEGRATION_NETLIFY: + apps = await getAppsNetlify({ + integrationAuth, + 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_API_URL}/apps`, { + headers: { + Accept: 'application/vnd.heroku+json; version=3', + Authorization: `Bearer ${accessToken}` + } + })).data; + + apps = res.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; +} + +/** + * Return list of names of apps for Vercel integration + * @param {Object} obj + * @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 +}: { + 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; +} + +/** + * 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 new file mode 100644 index 000000000..26e6521a1 --- /dev/null +++ b/backend/src/integrations/exchange.ts @@ -0,0 +1,241 @@ +import axios from 'axios'; +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, + CLIENT_SECRET_HEROKU, + CLIENT_ID_VERCEL, + CLIENT_ID_NETLIFY, + CLIENT_SECRET_VERCEL, + CLIENT_SECRET_NETLIFY +} 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; +} + +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] + * @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 + }); + break; + case INTEGRATION_VERCEL: + obj = await exchangeCodeVercel({ + code + }); + break; + case INTEGRATION_NETLIFY: + obj = await exchangeCodeNetlify({ + code + }); + 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: ExchangeCodeHerokuResponse; + let accessExpiresAt = new Date(); + try { + res = (await axios.post( + INTEGRATION_HEROKU_TOKEN_URL, + new URLSearchParams({ + grant_type: 'authorization_code', + code: code, + client_secret: CLIENT_SECRET_HEROKU + } as any) + )).data; + + accessExpiresAt.setSeconds( + accessExpiresAt.getSeconds() + res.expires_in + ); + } catch (err) { + Sentry.setUser(null); + Sentry.captureException(err); + throw new Error('Failed OAuth2 code-token exchange with Heroku'); + } + + return ({ + 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 + }); +} + +/** + * 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; +}) => { + let res: ExchangeCodeNetlifyResponse; + let accountId; + try { + res = (await axios.post( + INTEGRATION_NETLIFY_TOKEN_URL, + new URLSearchParams({ + grant_type: 'authorization_code', + code: code, + client_id: CLIENT_ID_NETLIFY, + client_secret: CLIENT_SECRET_NETLIFY, + redirect_uri: `${SITE_URL}/netlify` + } as any) + )).data; + + const res2 = await axios.get( + 'https://api.netlify.com/api/v1/sites', + { + headers: { + Authorization: `Bearer ${res.access_token}` + } + } + ); + + 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) { + 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 + }); +} + +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..86c22de0c --- /dev/null +++ b/backend/src/integrations/index.ts @@ -0,0 +1,13 @@ +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, + revokeAccess +} \ 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..16870944d --- /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 { + CLIENT_SECRET_HEROKU +} from '../config'; +import { + INTEGRATION_HEROKU_TOKEN_URL +} 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( + INTEGRATION_HEROKU_TOKEN_URL, + new URLSearchParams({ + grant_type: 'refresh_token', + refresh_token: refreshToken, + client_secret: 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/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/backend/src/integrations/sync.ts b/backend/src/integrations/sync.ts new file mode 100644 index 000000000..3cef519cc --- /dev/null +++ b/backend/src/integrations/sync.ts @@ -0,0 +1,429 @@ +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_NETLIFY_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 {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) + * @param {String} obj.accessToken - access token for integration + */ +const syncSecrets = async ({ + integration, + integrationAuth, + secrets, + accessToken, +}: { + integration: IIntegration; + integrationAuth: IIntegrationAuth; + secrets: any; + accessToken: string; +}) => { + try { + switch (integration.integration) { + case INTEGRATION_HEROKU: + await syncSecretsHeroku({ + integration, + secrets, + accessToken + }); + break; + case INTEGRATION_VERCEL: + await syncSecretsVercel({ + integration, + secrets, + accessToken + }); + break; + case INTEGRATION_NETLIFY: + await syncSecretsNetlify({ + integration, + integrationAuth, + 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 {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 ({ + integration, + secrets, + accessToken +}: { + integration: IIntegration, + secrets: any; + accessToken: string; +}) => { + try { + const herokuSecrets = (await axios.get( + `${INTEGRATION_HEROKU_API_URL}/apps/${integration.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( + `${INTEGRATION_HEROKU_API_URL}/apps/${integration.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'); + } +} + +/** + * Sync/push [secrets] to Heroku [app] + * @param {Object} obj + * @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 ({ + integration, + secrets, + accessToken +}: { + integration: IIntegration, + 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/${integration.app}/env`, + { + params, + headers: { + Authorization: `Bearer ${accessToken}` + } + } + )) + .data + .envs + .filter((secret: VercelSecret) => secret.target.includes(integration.target)) + .map(async (secret: VercelSecret) => (await axios.get( + `${INTEGRATION_VERCEL_API_URL}/v9/projects/${integration.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)) { + // case: secret has been created + newSecrets.push({ + key: key, + value: secrets[key], + type: 'encrypted', + target: [integration.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: [integration.target] + }); + } + } else { + // case: secret has been deleted + deleteSecrets.push({ + id: res[key].id, + key: key, + value: res[key].value, + type: 'encrypted', + target: [integration.target], + }); + } + }); + + // Sync/push new secrets + if (newSecrets.length > 0) { + await axios.post( + `${INTEGRATION_VERCEL_API_URL}/v10/projects/${integration.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/${integration.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/${integration.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'); + } +} + +/** + * 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/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/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..fe653dbc0 100644 --- a/backend/src/middleware/requireIntegrationAuth.ts +++ b/backend/src/middleware/requireIntegrationAuth.ts @@ -1,7 +1,8 @@ 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'; /** * 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( @@ -60,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 1f5c6dfc8..ed44ffec5 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 { getOAuthAccessToken } from '../helpers/integrationAuth'; +import { IntegrationAuth } from '../models'; +import { IntegrationService } from '../services'; +import { validateMembership } from '../helpers/membership'; /** * Validate if user on request is a member of workspace with proper roles associated @@ -10,18 +10,18 @@ import { getOAuthAccessToken } from '../helpers/integrationAuth'; * @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) => { - // (authorization) integration authorization middleware - try { const { integrationAuthId } = req.params; @@ -34,30 +34,21 @@ 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 }); + if (attachAccessToken) { + req.accessToken = await IntegrationService.getIntegrationAuthAccess({ + integrationAuthId: integrationAuth._id.toString() + }); + } + return next(); } catch (err) { Sentry.setUser(null); 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..c7e5a9abe --- /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, + default: false + }, + publicKey: { + type: String, + required: true + }, + 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/index.ts b/backend/src/models/index.ts index 9b07f6766..78c38060b 100644 --- a/backend/src/models/index.ts +++ b/backend/src/models/index.ts @@ -1,4 +1,6 @@ import BackupPrivateKey, { IBackupPrivateKey } from './backupPrivateKey'; +import Bot, { IBot } from './bot'; +import BotKey, { IBotKey } from './botKey'; import IncidentContactOrg, { IIncidentContactOrg } from './incidentContactOrg'; import Integration, { IIntegration } from './integration'; import IntegrationAuth, { IIntegrationAuth } from './integrationAuth'; @@ -16,6 +18,10 @@ import Workspace, { IWorkspace } from './workspace'; export { BackupPrivateKey, IBackupPrivateKey, + Bot, + IBot, + BotKey, + IBotKey, IncidentContactOrg, IIncidentContactOrg, Integration, diff --git a/backend/src/models/integration.ts b/backend/src/models/integration.ts index 5e72e8b54..edbe0234e 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,7 +15,10 @@ export interface IIntegration { environment: 'dev' | 'test' | 'staging' | 'prod'; isActive: boolean; app: string; - integration: 'heroku' | 'netlify'; + target: string; + context: string; + siteId: string; + integration: 'heroku' | 'vercel' | 'netlify'; integrationAuth: Types.ObjectId; } @@ -34,15 +38,29 @@ const integrationSchema = new Schema( type: Boolean, required: true }, - app: { - // name of app in provider + app: { // name of app in provider type: String, - default: null, - required: true + default: null + }, + target: { // vercel-specific target (environment) + 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: [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..0da3eb0d8 100644 --- a/backend/src/models/integrationAuth.ts +++ b/backend/src/models/integrationAuth.ts @@ -1,10 +1,16 @@ 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'; + integration: 'heroku' | 'vercel' | 'netlify'; + teamId: string; + accountId: string; refreshCiphertext?: string; refreshIV?: string; refreshTag?: string; @@ -22,9 +28,19 @@ 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 + type: String + }, + accountId: { // netlify-specific integration param + type: String + }, refreshCiphertext: { type: String, select: false diff --git a/backend/src/routes/bot.ts b/backend/src/routes/bot.ts new file mode 100644 index 000000000..3189bec44 --- /dev/null +++ b/backend/src/routes/bot.ts @@ -0,0 +1,38 @@ +import express from 'express'; +const router = express.Router(); +import { body, param } from 'express-validator'; +import { + requireAuth, + requireBotAuth, + requireWorkspaceAuth, + validateRequest +} from '../middleware'; +import { botController } from '../controllers'; +import { ADMIN, MEMBER, COMPLETED, GRANTED } from '../variables'; + +router.get( + '/:workspaceId', + requireAuth, + requireWorkspaceAuth({ + acceptedRoles: [ADMIN, MEMBER], + acceptedStatuses: [COMPLETED, GRANTED] + }), + param('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..e6738a803 100644 --- a/backend/src/routes/integration.ts +++ b/backend/src/routes/integration.ts @@ -9,22 +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.post( - '/: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, @@ -32,10 +16,15 @@ 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(), + body('target').exists(), + body('context').exists(), + body('siteId').exists(), validateRequest, - integrationController.modifyIntegration + integrationController.updateIntegration ); router.delete( @@ -45,7 +34,7 @@ router.delete( acceptedRoles: [ADMIN, MEMBER], acceptedStatuses: [GRANTED] }), - param('integrationId'), + param('integrationId').exists().trim(), validateRequest, integrationController.deleteIntegration ); diff --git a/backend/src/routes/integrationAuth.ts b/backend/src/routes/integrationAuth.ts index 61e5f56bf..ef80a2dcc 100644 --- a/backend/src/routes/integrationAuth.ts +++ b/backend/src/routes/integrationAuth.ts @@ -10,6 +10,12 @@ import { import { ADMIN, MEMBER, GRANTED } from '../variables'; import { integrationAuthController } from '../controllers'; +router.get( + '/integration-options', + requireAuth, + integrationAuthController.getIntegrationOptions +); + router.post( '/oauth-token', requireAuth, @@ -22,7 +28,7 @@ router.post( body('code').exists().trim().notEmpty(), body('integration').exists().trim().notEmpty(), validateRequest, - integrationAuthController.integrationAuthOauthExchange + integrationAuthController.oAuthExchange ); router.get( @@ -42,7 +48,8 @@ router.delete( requireAuth, requireIntegrationAuthorizationAuth({ acceptedRoles: [ADMIN, MEMBER], - acceptedStatuses: [GRANTED] + acceptedStatuses: [GRANTED], + attachAccessToken: false }), param('integrationAuthId'), validateRequest, diff --git a/backend/src/services/BotService.ts b/backend/src/services/BotService.ts new file mode 100644 index 000000000..792bd8e35 --- /dev/null +++ b/backend/src/services/BotService.ts @@ -0,0 +1,82 @@ +import { + getSecretsHelper, + encryptSymmetricHelper, + decryptSymmetricHelper +} from '../helpers/bot'; + +/** + * Class to handle bot actions + */ +class BotService { + + /** + * 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 getSecrets({ + workspaceId, + environment + }: { + workspaceId: string; + environment: string; + }) { + 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 + }); + } +} + +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..fcbac9ad0 --- /dev/null +++ b/backend/src/services/EventService.ts @@ -0,0 +1,30 @@ +import { Bot, IBot } from '../models'; +import * as Sentry from '@sentry/node'; +import { handleEventHelper } from '../helpers/event'; + +interface Event { + name: string; + workspaceId: string; + payload: any; +} + +/** + * Class to handle events. + */ +class EventService { + /** + * 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) + */ + static async handleEvent({ event }: { event: Event }): Promise { + await handleEventHelper({ + event + }); + } +} + +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..32f5f5a88 --- /dev/null +++ b/backend/src/services/IntegrationService.ts @@ -0,0 +1,145 @@ +import * as Sentry from '@sentry/node'; +import { + Integration +} from '../models'; +import { + handleOAuthExchangeHelper, + syncIntegrationsHelper, + getIntegrationAuthRefreshHelper, + getIntegrationAuthAccessHelper, + setIntegrationAuthRefreshHelper, + setIntegrationAuthAccessHelper, +} from '../helpers/integration'; +import { exchangeCode } from '../integrations'; +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 + */ +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; + }) { + 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 + }); + } + + /** + * 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 + }); + } +} + +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..531033f30 100644 --- a/backend/src/services/index.ts +++ b/backend/src/services/index.ts @@ -1,5 +1,11 @@ import postHogClient from './PostHogClient'; +import BotService from './BotService'; +import EventService from './EventService'; +import IntegrationService from './IntegrationService'; export { - postHogClient + postHogClient, + BotService, + EventService, + 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..585dae51d 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. @@ -81,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, @@ -114,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 = ({ @@ -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..b21324423 --- /dev/null +++ b/backend/src/variables/index.ts @@ -0,0 +1,79 @@ +import { + ENV_DEV, + ENV_TESTING, + ENV_STAGING, + ENV_PROD, + ENV_SET +} from './environment'; +import { + INTEGRATION_HEROKU, + INTEGRATION_VERCEL, + INTEGRATION_NETLIFY, + INTEGRATION_SET, + 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, + INTEGRATION_OPTIONS +} 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_VERCEL, + INTEGRATION_NETLIFY, + INTEGRATION_SET, + 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, + INTEGRATION_OPTIONS +}; \ 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..c02a2389f --- /dev/null +++ b/backend/src/variables/integration.ts @@ -0,0 +1,117 @@ +import { + CLIENT_ID_HEROKU, + CLIENT_ID_NETLIFY +} from '../config'; + +// integrations +const INTEGRATION_HEROKU = 'heroku'; +const INTEGRATION_VERCEL = 'vercel'; +const INTEGRATION_NETLIFY = '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'; +const INTEGRATION_NETLIFY_TOKEN_URL = 'https://api.netlify.com/oauth/token'; + +// 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'; + +const INTEGRATION_OPTIONS = [ + { + name: 'Heroku', + slug: 'heroku', + image: 'Heroku', + isAvailable: true, + type: 'oauth2', + clientId: CLIENT_ID_HEROKU, + docsLink: '' + }, + { + name: 'Vercel', + slug: 'vercel', + image: 'Vercel', + isAvailable: true, + type: 'vercel', + clientId: '', + docsLink: '' + }, + { + name: 'Netlify', + slug: 'netlify', + image: 'Netlify', + isAvailable: true, + type: 'oauth2', + clientId: CLIENT_ID_NETLIFY, + docsLink: '' + }, + { + name: 'Google Cloud Platform', + slug: 'gcp', + image: 'Google Cloud Platform', + isAvailable: false, + type: '', + clientId: '', + docsLink: '' + }, + { + name: 'Amazon Web Services', + slug: 'aws', + image: 'Amazon Web Services', + isAvailable: false, + type: '', + clientId: '', + docsLink: '' + }, + { + name: 'Microsoft Azure', + slug: 'azure', + image: 'Microsoft Azure', + isAvailable: false, + type: '', + clientId: '', + docsLink: '' + }, + { + name: 'Travis CI', + slug: 'travisci', + image: 'Travis CI', + isAvailable: false, + type: '', + clientId: '', + docsLink: '' + }, + { + name: 'Circle CI', + slug: 'circleci', + image: 'Circle CI', + isAvailable: false, + type: '', + clientId: '', + docsLink: '' + } +] + +export { + INTEGRATION_HEROKU, + INTEGRATION_VERCEL, + INTEGRATION_NETLIFY, + INTEGRATION_SET, + 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, + INTEGRATION_OPTIONS +} \ 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 diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 623462d5b..15a200783 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -51,6 +51,7 @@ services: env_file: .env environment: - NEXT_PUBLIC_ENV=development + - INFISICAL_TELEMETRY_ENABLED=${TELEMETRY_ENABLED} - NEXT_PUBLIC_STRIPE_PRODUCT_PRO=${STRIPE_PRODUCT_PRO} - NEXT_PUBLIC_STRIPE_PRODUCT_STARTER=${STRIPE_PRODUCT_STARTER} networks: 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/basic/dialog/ActivateBotDialog.js b/frontend/components/basic/dialog/ActivateBotDialog.js new file mode 100644 index 000000000..600b61c20 --- /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 + +
+

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

+
+
+
+
+
+
+
+
+
+
+ ); +} + +export default ActivateBotDialog; \ No newline at end of file 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/components/integrations/CloudIntegration.tsx b/frontend/components/integrations/CloudIntegration.tsx new file mode 100644 index 000000000..f64268dba --- /dev/null +++ b/frontend/components/integrations/CloudIntegration.tsx @@ -0,0 +1,121 @@ +import React from "react"; +import Image from "next/image"; +import { useRouter } from "next/router"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { + faCheck, + faX, + } from "@fortawesome/free-solid-svg-icons"; +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: IntegrationAuth[]; +} + +const CloudIntegration = ({ + cloudIntegrationOption, + setSelectedIntegrationOption, + integrationOptionPress, + integrationAuths +}: Props) => { + const router = useRouter(); + return integrationAuths ? ( +
{ + 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} +
+ )} + {cloudIntegrationOption.isAvailable && + integrationAuths + .map((authorization) => authorization.integration) + .includes(cloudIntegrationOption.name.toLowerCase()) && ( +
+
{ + event.stopPropagation(); + 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" + > + + Revoke +
+
+ + Authorized +
+
+ )} + {!cloudIntegrationOption.isAvailable && ( +
+
+ Coming Soon +
+
+ )} +
+ ) :
+} + +export default CloudIntegration; \ No newline at end of file diff --git a/frontend/components/integrations/CloudIntegrationSection.tsx b/frontend/components/integrations/CloudIntegrationSection.tsx new file mode 100644 index 000000000..87ff74df1 --- /dev/null +++ b/frontend/components/integrations/CloudIntegrationSection.tsx @@ -0,0 +1,47 @@ +import React from "react"; +import CloudIntegration from "./CloudIntegration"; + +interface CloudIntegrationOption { + name: string; + type: string; + clientId: string; + docsLink: string; +} + +interface Props { + cloudIntegrationOptions: CloudIntegrationOption[]; + setSelectedIntegrationOption: () => void; + integrationOptionPress: () => void; + integrationAuths: any; +} + +const CloudIntegrationSection = ({ + cloudIntegrationOptions, + setSelectedIntegrationOption, + integrationOptionPress, + integrationAuths +}: Props) => { + return ( + <> +
+

Cloud Integrations

+

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

+
+
+ {cloudIntegrationOptions.map((cloudIntegrationOption) => ( + + ))} +
+ + ); +} + +export default CloudIntegrationSection; \ 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..432dbad51 --- /dev/null +++ b/frontend/components/integrations/FrameworkIntegration.tsx @@ -0,0 +1,36 @@ +import React from "react"; +import Image from "next/image"; + +interface Framework { + name: string; + slug: string; + image: string; + docsLink: string; +} + +const FrameworkIntegration = ({ + framework +}: { + framework: Framework; +}) => { + return ( + +
1 ? "text-sm px-1" : "text-xl px-2"} text-center w-full max-w-xs`}> + {framework?.image && integration logo} + {framework?.name && framework?.image &&
} + {framework?.name && framework.name} +
+
+ ); +} + +export default FrameworkIntegration; diff --git a/frontend/components/integrations/FrameworkIntegrationSection.tsx b/frontend/components/integrations/FrameworkIntegrationSection.tsx new file mode 100644 index 000000000..c83599dc1 --- /dev/null +++ b/frontend/components/integrations/FrameworkIntegrationSection.tsx @@ -0,0 +1,36 @@ +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/Integration.tsx b/frontend/components/integrations/Integration.tsx new file mode 100644 index 000000000..a42fa5bcf --- /dev/null +++ b/frontend/components/integrations/Integration.tsx @@ -0,0 +1,209 @@ +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 { + envMapping, + reverseEnvMapping, + reverseContextNetlifyMapping +} 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 Integration { + app?: string; + environment: string; + integration: string; + integrationAuth: string; + isActive: Boolean; +} + +const Integration = ({ + integration +}: { + integration: Integration; +}) => { + const [integrationEnvironment, setIntegrationEnvironment] = useState( + reverseEnvMapping[integration.environment] + ); + const [fileState, setFileState] = useState([]); + const router = useRouter(); + 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, + }); + + setApps(tempApps); + setIntegrationApp( + integration.app ? integration.app : tempApps[0].name + ); + + 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 ( +
+
+
+

ENVIRONMENT

+ +
+
+ +
+
+

+ INTEGRATION +

+
+ {integration.integration.charAt(0).toUpperCase() + + integration.integration.slice(1)} +
+
+
+
+ APP +
+ app.name)} + selected={integrationApp} + onChange={setIntegrationApp} + /> +
+ {renderIntegrationSpecificParams(integration)} +
+
+ {integration.isActive ? ( +
+ +
In Sync
+
+ ) : ( +
+
+ + ); + }; + +export default Integration; \ No newline at end of file diff --git a/frontend/components/integrations/IntegrationSection.tsx b/frontend/components/integrations/IntegrationSection.tsx new file mode 100644 index 000000000..cfa43b4ef --- /dev/null +++ b/frontend/components/integrations/IntegrationSection.tsx @@ -0,0 +1,34 @@ +import React from "react"; +import Integration from "./Integration"; +import guidGenerator from "~/utilities/randomId"; + +interface Integration { + +} + +interface Props { + integrations: any +} + +const ProjectIntegrationSection = ({ + integrations +}: Props) => { + return integrations.length > 0 ? ( +
+
+

Current Integrations

+

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

+
+ {integrations.map((integration => ( + + )))} +
+ ) :
+} + +export default ProjectIntegrationSection; \ No newline at end of file diff --git a/frontend/components/utilities/attemptLogin.js b/frontend/components/utilities/attemptLogin.js index 228f58727..b0d70146e 100644 --- a/frontend/components/utilities/attemptLogin.js +++ b/frontend/components/utilities/attemptLogin.js @@ -73,7 +73,7 @@ const attemptLogin = async ( tag, privateKey }); - + const userOrgs = await getOrganizations(); const userOrgsData = userOrgs.map((org) => org._id); diff --git a/frontend/components/utilities/config/index.ts b/frontend/components/utilities/config/index.ts index d0ffed00c..42dee5618 100644 --- a/frontend/components/utilities/config/index.ts +++ b/frontend/components/utilities/config/index.ts @@ -4,6 +4,8 @@ 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 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 +13,6 @@ export { POSTHOG_HOST, STRIPE_PRODUCT_PRO, STRIPE_PRODUCT_STARTER, -}; + CLIENT_ID_HEROKU, + CLIENT_ID_NETLIFY +}; \ No newline at end of file diff --git a/frontend/pages/api/bot/getBot.ts b/frontend/pages/api/bot/getBot.ts new file mode 100644 index 000000000..145b50891 --- /dev/null +++ b/frontend/pages/api/bot/getBot.ts @@ -0,0 +1,31 @@ +import SecurityClient from "~/utilities/SecurityClient"; + +interface Props { + workspaceId: string; +} + +/** + * This function fetches the bot for a project + * @param {Object} obj + * @param {String} obj.workspaceId + * @returns + */ +const getBot = async ({ workspaceId }: Props) => { + return SecurityClient.fetchCall( + "/api/v1/bot/" + workspaceId, + { + method: "GET", + headers: { + "Content-Type": "application/json", + } + } + ).then(async (res) => { + if (res && res.status == 200) { + return (await res.json()).bot; + } 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.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/api/integrations/GetIntegrationOptions.ts b/frontend/pages/api/integrations/GetIntegrationOptions.ts new file mode 100644 index 000000000..caf0c8626 --- /dev/null +++ b/frontend/pages/api/integrations/GetIntegrationOptions.ts @@ -0,0 +1,21 @@ +import SecurityClient from '~/utilities/SecurityClient'; + +const getIntegrationOptions = () => { + return SecurityClient.fetchCall( + '/api/v1/integration-auth/integration-options', + { + method: 'GET', + headers: { + 'Content-Type': 'application/json' + } + } + ).then(async (res) => { + if (res && res.status == 200) { + return (await res.json()).integrationOptions; + } else { + console.log('Failed to get (cloud) integration options'); + } + }); +}; + +export default getIntegrationOptions; 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/api/integrations/updateIntegration.js b/frontend/pages/api/integrations/updateIntegration.js new file mode 100644 index 000000000..c77297e3a --- /dev/null +++ b/frontend/pages/api/integrations/updateIntegration.js @@ -0,0 +1,51 @@ +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 + * @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 = ({ + integrationId, + app, + environment, + isActive, + target, + context, + siteId +}) => { + return SecurityClient.fetchCall( + "/api/v1/integration/" + integrationId, + { + method: "PATCH", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + app, + environment, + isActive, + target, + context, + siteId + }), + } + ).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/dashboard/[id].js b/frontend/pages/dashboard/[id].js index 4bb09c80f..36f5eb3f2 100644 --- a/frontend/pages/dashboard/[id].js +++ b/frontend/pages/dashboard/[id].js @@ -413,29 +413,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.key]: row.value })) - ); - 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/heroku.js b/frontend/pages/heroku.js index 82947b1b9..088c96500 100644 --- a/frontend/pages/heroku.js +++ b/frontend/pages/heroku.js @@ -16,16 +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.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 dc13d284c..494abee43 100644 --- a/frontend/pages/integrations/[id].js +++ b/frontend/pages/integrations/[id].js @@ -1,199 +1,178 @@ -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 pushKeysIntegration from '~/components/utilities/secrets/pushKeysIntegration'; -import guidGenerator from '~/utilities/randomId'; - -import { - envMapping, - frameworks, - reverseEnvMapping -} 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 startIntegration from '../api/integrations/StartIntegration'; - -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
-
- ) : ( -
-
-
- - ); -}; +import React, { useEffect, useState } from "react"; +import Head from "next/head"; +import Image from "next/image"; +import { useRouter } from "next/router"; +import NavHeader from "~/components/navigation/NavHeader"; +import Integration from "~/components/integrations/Integration"; +import FrameworkIntegrationSection from "~/components/integrations/FrameworkIntegrationSection"; +import CloudIntegrationSection from "~/components/integrations/CloudIntegrationSection"; +import IntegrationSection from "~/components/integrations/IntegrationSection"; +import frameworkIntegrationOptions from "../../public/json/frameworkIntegrations.json"; +import getWorkspaceAuthorizations from "../api/integrations/getWorkspaceAuthorizations"; +import getWorkspaceIntegrations from "../api/integrations/getWorkspaceIntegrations"; +import getIntegrationOptions from "../api/integrations/GetIntegrationOptions"; +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 +} = require('../../components/utilities/cryptography/crypto'); +const crypto = require("crypto"); export default function Integrations() { - const [integrations, setIntegrations] = useState(); - const [projectIntegrations, setProjectIntegrations] = useState(); - const [authorizations, setAuthorizations] = useState(); + const [cloudIntegrationOptions, setCloudIntegrationOptions] = useState([]); + const [integrationAuths, setIntegrationAuths] = useState([]); + const [integrations, setIntegrations] = useState([]); + const [bot, setBot] = useState(null); + const [isActivateBotDialogOpen, setIsActivateBotDialogOpen] = useState(false); + // const [isIntegrationAccessTokenDialogOpen, setIntegrationAccessTokenDialogOpen] = useState(true); + const [selectedIntegrationOption, setSelectedIntegrationOption] = useState(null); + const router = useRouter(); - const [csrfToken, setCsrfToken] = useState(''); useEffect(async () => { - const tempCSRFToken = crypto.randomBytes(16).toString('hex'); - setCsrfToken(tempCSRFToken); - localStorage.setItem('latestCSRFToken', tempCSRFToken); - - let projectAuthorizations = await getWorkspaceAuthorizations({ - workspaceId: router.query.id - }); - setAuthorizations(projectAuthorizations); - - const projectIntegrations = await getWorkspaceIntegrations({ - workspaceId: router.query.id - }); - setProjectIntegrations(projectIntegrations); - try { - const integrationsData = await getIntegrations(); - setIntegrations(integrationsData); - } catch (error) { - console.log('Error', error); + // get cloud integration options + setCloudIntegrationOptions( + await getIntegrationOptions() + ); + + // get project integration authorizations + setIntegrationAuths( + await getWorkspaceAuthorizations({ + workspaceId: router.query.id, + }) + ); + + // get project integrations + setIntegrations( + await getWorkspaceIntegrations({ + workspaceId: router.query.id, + }) + ); + + // get project bot + setBot( + await getBot({ + workspaceId: router.query.id + } + )); + + } catch (err) { + console.log(err); } }, []); - return integrations ? ( + /** + * 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 () => { + let botKey; + try { + + if (bot) { + // case: there is a bot + 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 + }); + + botKey = { + encryptedKey: ciphertext, + nonce + } + + setBot((await setBotActiveStatus({ + botId: bot._id, + isActive: bot.isActive ? false : true, + botKey + })).bot); + } + } catch (err) { + console.error(err); + } + } + + /** + * 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 }) => { + + 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-dev/new?state=${state}`; + break; + case 'Netlify': + window.location = `https://app.netlify.com/authorize?client_id=${integrationOption.clientId}&response_type=code&state=${state}&redirect_uri=${window.location.origin}/netlify`; + break; + // case 'Fly.io': + // console.log('fly.io'); + // setIntegrationAccessTokenDialogOpen(true); + // break; + } + } catch (err) { + console.log(err); + } + } + + /** + * Open dialog to activate bot if bot is not active. + * Otherwise, start integration [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 integrationOptionPress = ({ integrationOption }) => { + try { + if (bot.isActive) { + // case: bot is active -> proceed with integration + handleIntegrationOption({ integrationOption }); + return; + } + + // case: bot is not active -> open modal to activate bot + setIsActivateBotDialogOpen(true); + } catch (err) { + console.error(err); + } + } + + return (
Dashboard @@ -205,200 +184,41 @@ export default function Integrations() { content="Infisical a simple end-to-end encrypted platform that enables teams to sync and manage their .env files." /> -
-
- -
-
-

Current Project Integrations

-
-

- 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. -
-
-
- )} -
-
-

- Platform & 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. -

-
-
- {Object.keys(integrations).map((integration) => ( -
- - 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 -
-
- )} -
- ))} -
-
-
-

Framework Integrations

-
-

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

-
- -
+
+ + setIsActivateBotDialogOpen(false)} + selectedIntegrationOption={selectedIntegrationOption} + handleBotActivate={handleBotActivate} + handleIntegrationOption={handleIntegrationOption} + /> + {/* setIntegrationAccessTokenDialogOpen(false)} + selectedIntegrationOption={selectedIntegrationOption} + handleBotActivate={handleBotActivate} + handleIntegrationOption={handleIntegrationOption} + /> */} + + {cloudIntegrationOptions.length > 0 ? ( + + ) : ( +
+ )} +
- ) : ( -
-
- loading animation -
); } diff --git a/frontend/pages/netlify.js b/frontend/pages/netlify.js new file mode 100644 index 000000000..f25c6af83 --- /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]); + 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 () => { + if (state === localStorage.getItem('latestCSRFToken')) { + localStorage.removeItem('latestCSRFToken'); + + await AuthorizeIntegration({ + workspaceId: localStorage.getItem('projectData.id'), + code, + integration: "netlify" + }); + + 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 new file mode 100644 index 000000000..a674c27d4 --- /dev/null +++ b/frontend/pages/vercel.js @@ -0,0 +1,42 @@ +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]); + const code = parsedUrl.code; + const state = parsedUrl.state + + /** + * 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'); + + await AuthorizeIntegration({ + workspaceId: localStorage.getItem('projectData.id'), + code, + integration: "vercel" + }); + + router.push("/integrations/" + localStorage.getItem("projectData.id")); + } + + try { + + } catch (err) { + console.error('Vercel integration error: ', 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..bbaa42ade 100644 --- a/frontend/public/data/frequentConstants.ts +++ b/frontend/public/data/frequentConstants.ts @@ -12,71 +12,20 @@ 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" - } -] +const vercelMapping = { + +} + +const reverseContextNetlifyMapping = { + "All": "all", + "Local development": "dev", + "Branch deploys": "branch-deploy", + "Deploy Previews": "deploy-preview", + "Production": "production" +} export { envMapping, - frameworks, - reverseEnvMapping + reverseEnvMapping, + reverseContextNetlifyMapping }; diff --git a/frontend/public/images/integrations/Vercel.png b/frontend/public/images/integrations/Vercel.png new file mode 100644 index 000000000..7bdcd2a19 Binary files /dev/null and b/frontend/public/images/integrations/Vercel.png differ 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