diff --git a/backend/src/controllers/integrationController.ts b/backend/src/controllers/integrationController.ts index f6f1aa3a28..665343bd40 100644 --- a/backend/src/controllers/integrationController.ts +++ b/backend/src/controllers/integrationController.ts @@ -1,11 +1,9 @@ import { Request, Response } from 'express'; import { readFileSync } from 'fs'; import * as Sentry from '@sentry/node'; -import axios from 'axios'; -import { Integration } from '../models'; -import { decryptAsymmetric } from '../utils/crypto'; -import { decryptSecrets } from '../helpers/secret'; -import { PRIVATE_KEY } from '../config'; +import { Integration, Bot, BotKey } from '../models'; +import { EventService } from '../services'; +import { eventPushSecrets } from '../events'; interface Key { encryptedKey: string; @@ -55,26 +53,40 @@ export const getIntegrations = async (req: Request, res: Response) => { * @param res * @returns */ -export const modifyIntegration = async (req: Request, res: Response) => { +export const updateIntegration = async (req: Request, res: Response) => { let integration; try { - const { update } = req.body; + const { app, environment, isActive } = req.body; integration = await Integration.findOneAndUpdate( { _id: req.integration._id }, - update, + { + app, + environment, + isActive + }, { new: true } ); + + if (integration) { + + // trigger event - push secrets + EventService.handleEvent({ + event: eventPushSecrets({ + workspaceId: integration.workspace.toString() + }) + }); + } } catch (err) { Sentry.setUser({ email: req.user.email }); Sentry.captureException(err); return res.status(400).send({ - message: 'Failed to modify integration' + message: 'Failed to update integration' }); } @@ -84,7 +96,8 @@ export const modifyIntegration = async (req: Request, res: Response) => { }; /** - * Delete integration with id [integrationId] + * Delete integration with id [integrationId] and deactivate bot if there are + * no integrations left * @param req * @param res * @returns @@ -97,6 +110,29 @@ export const deleteIntegration = async (req: Request, res: Response) => { deletedIntegration = await Integration.findOneAndDelete({ _id: integrationId }); + + if (!deletedIntegration) throw new Error('Failed to find integration'); + + const integrations = await Integration.find({ + workspace: deletedIntegration.workspace + }); + + if (integrations.length === 0) { + // case: no integrations left, deactivate bot + const bot = await Bot.findOneAndUpdate({ + workspace: deletedIntegration.workspace + }, { + isActive: false + }, { + new: true + }); + + if (bot) { + await BotKey.deleteOne({ + bot: bot._id + }); + } + } } catch (err) { Sentry.setUser({ email: req.user.email }); Sentry.captureException(err); diff --git a/backend/src/controllers/secretController.ts b/backend/src/controllers/secretController.ts index cd91d3dbe5..bfd9aee1ff 100644 --- a/backend/src/controllers/secretController.ts +++ b/backend/src/controllers/secretController.ts @@ -62,14 +62,6 @@ export const pushSecrets = async (req: Request, res: Response) => { keys }); - // trigger event - EventService.handleEvent({ - event: eventPushSecrets({ - workspaceId, - environment, - secrets - }) - }); if (postHogClient) { postHogClient.capture({ @@ -84,6 +76,13 @@ export const pushSecrets = async (req: Request, res: Response) => { }); } + // trigger event - push secrets + EventService.handleEvent({ + event: eventPushSecrets({ + workspaceId + }) + }); + } catch (err) { Sentry.setUser({ email: req.user.email }); Sentry.captureException(err); diff --git a/backend/src/events/secret.ts b/backend/src/events/secret.ts index 771602d0e5..8bb3a86c3a 100644 --- a/backend/src/events/secret.ts +++ b/backend/src/events/secret.ts @@ -16,25 +16,18 @@ interface PushSecret { * Return event for pushing secrets * @param {Object} obj * @param {String} obj.workspaceId - id of workspace to push secrets to - * @param {String} obj.environment - environment for secrets - * @param {PushSecret[]} obj.secrets - secrets to push * @returns */ const eventPushSecrets = ({ workspaceId, - environment, - secrets }: { workspaceId: string; - environment: string; - secrets: PushSecret[]; }) => { return ({ name: EVENT_PUSH_SECRETS, workspaceId, payload: { - environment, - secrets + } }); } diff --git a/backend/src/helpers/integration.ts b/backend/src/helpers/integration.ts index c4caeec9db..a9d62f92f0 100644 --- a/backend/src/helpers/integration.ts +++ b/backend/src/helpers/integration.ts @@ -73,20 +73,18 @@ const handleOAuthExchangeHelper = async ({ accessExpiresAt: res.accessExpiresAt }); - // initializes an integration after exchange - await Integration.findOneAndUpdate( - { workspace: workspaceId, integration }, - { - workspace: workspaceId, - environment: ENV_DEV, - isActive: false, - app: null, - integration, - integrationAuth: integrationAuth._id - }, - { upsert: true, new: true } - ); + // initialize new integration after exchange + await new Integration({ + workspace: workspaceId, + environment: ENV_DEV, + isActive: false, + app: null, + integration, + integrationAuth: integrationAuth._id + }).save(); + } catch (err) { + console.error('in', err); Sentry.setUser(null); Sentry.captureException(err); throw new Error('Failed to handle OAuth2 code-token exchange') diff --git a/backend/src/models/integration.ts b/backend/src/models/integration.ts index 5e72e8b544..90e3f3019e 100644 --- a/backend/src/models/integration.ts +++ b/backend/src/models/integration.ts @@ -37,8 +37,7 @@ const integrationSchema = new Schema( app: { // name of app in provider type: String, - default: null, - required: true + default: null }, integration: { type: String, diff --git a/backend/src/routes/integration.ts b/backend/src/routes/integration.ts index 0808973f7d..4d025b1e8e 100644 --- a/backend/src/routes/integration.ts +++ b/backend/src/routes/integration.ts @@ -18,10 +18,12 @@ router.patch( acceptedRoles: [ADMIN, MEMBER], acceptedStatuses: [GRANTED] }), - param('integrationId'), - body('update'), + param('integrationId').exists().trim(), + body('app').exists().trim(), + body('environment').exists().trim(), + body('isActive').exists().isBoolean(), validateRequest, - integrationController.modifyIntegration + integrationController.updateIntegration ); router.delete( @@ -31,7 +33,7 @@ router.delete( acceptedRoles: [ADMIN, MEMBER], acceptedStatuses: [GRANTED] }), - param('integrationId'), + param('integrationId').exists().trim(), validateRequest, integrationController.deleteIntegration ); diff --git a/frontend/components/basic/dialog/ActivateBotDialog.js b/frontend/components/basic/dialog/ActivateBotDialog.js new file mode 100644 index 0000000000..ece0dcb523 --- /dev/null +++ b/frontend/components/basic/dialog/ActivateBotDialog.js @@ -0,0 +1,91 @@ +import { Fragment } from "react"; +import { Dialog, Transition } from "@headlessui/react"; +import getLatestFileKey from "../../../pages/api/workspace/getLatestFileKey"; +import setBotActiveStatus from "../../../pages/api/bot/setBotActiveStatus"; +import { + decryptAssymmetric, + encryptAssymmetric +} from "../../utilities/cryptography/crypto"; +import Button from "../buttons/Button"; + +const ActivateBotDialog = ({ + isOpen, + closeModal, + selectedIntegrationOption, + handleBotActivate, + handleIntegrationOption +}) => { + + const submit = async () => { + try { + // 1. activate bot + await handleBotActivate(); + + // 2. start integration + await handleIntegrationOption({ + integrationOption: selectedIntegrationOption + }); + } catch (err) { + console.log(err); + } + + closeModal(); + } + + return ( +
+ + + +
+ +
+
+ + + + Grant Infisical access to your secrets + +
+

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

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

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

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

Current Project Integrations

+

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

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

Platform & Cloud Integrations

@@ -302,30 +338,24 @@ export default function Integrations() {
{ + if (!["Heroku"].includes(integrations[integration].name)) return; + setSelectedIntegrationOption(integrations[integration]); + integrationOptionPress({ + integrationOption: integrations[integration] + }); + }} key={integrations[integration].name} > - integration logo + /> {integrations[integration].name.split(" ").length > 2 ? (
{integrations[integration].name.split(" ")[0]}
@@ -339,7 +369,6 @@ export default function Integrations() { {integrations[integration].name}
)} -
{["Heroku"].includes(integrations[integration].name) && authorizations .map((authorization) => authorization.integration)