From 0f81c78639d612ca3085cbead5f14f5ca601fc3b Mon Sep 17 00:00:00 2001 From: akhilmhdh Date: Tue, 11 Jul 2023 22:52:51 +0530 Subject: [PATCH 1/5] feat(webhook): implemented api for webhooks --- backend/src/controllers/v1/index.ts | 34 +-- .../controllers/v1/integrationController.ts | 42 ++-- .../src/controllers/v1/secretController.ts | 3 +- .../src/controllers/v1/webhookController.ts | 140 +++++++++++++ .../src/controllers/v2/secretsController.ts | 60 ++++-- .../src/controllers/v2/workspaceController.ts | 194 ++++++++---------- .../src/controllers/v3/secretsController.ts | 126 +++++------- backend/src/events/index.ts | 7 +- backend/src/events/integration.ts | 23 +++ backend/src/events/secret.ts | 76 +++---- backend/src/helpers/event.ts | 32 ++- backend/src/index.ts | 16 +- backend/src/models/webhooks.ts | 85 ++++++++ backend/src/routes/v1/index.ts | 38 ++-- backend/src/routes/v1/webhook.ts | 75 +++++++ backend/src/routes/v2/secrets.ts | 65 ++---- backend/src/services/WebhookService.ts | 93 +++++++++ backend/src/variables/event.ts | 3 +- 18 files changed, 748 insertions(+), 364 deletions(-) create mode 100644 backend/src/controllers/v1/webhookController.ts create mode 100644 backend/src/events/integration.ts create mode 100644 backend/src/models/webhooks.ts create mode 100644 backend/src/routes/v1/webhook.ts create mode 100644 backend/src/services/WebhookService.ts diff --git a/backend/src/controllers/v1/index.ts b/backend/src/controllers/v1/index.ts index 488c9c6f2c..a2664c7b01 100644 --- a/backend/src/controllers/v1/index.ts +++ b/backend/src/controllers/v1/index.ts @@ -14,22 +14,24 @@ import * as userActionController from "./userActionController"; import * as userController from "./userController"; import * as workspaceController from "./workspaceController"; import * as secretScanningController from "./secretScanningController"; +import * as webhookController from "./webhookController"; export { - authController, - botController, - integrationAuthController, - integrationController, - keyController, - membershipController, - membershipOrgController, - organizationController, - passwordController, - secretController, - serviceTokenController, - signupController, - userActionController, - userController, - workspaceController, - secretScanningController + authController, + botController, + integrationAuthController, + integrationController, + keyController, + membershipController, + membershipOrgController, + organizationController, + passwordController, + secretController, + serviceTokenController, + signupController, + userActionController, + userController, + workspaceController, + secretScanningController, + webhookController }; diff --git a/backend/src/controllers/v1/integrationController.ts b/backend/src/controllers/v1/integrationController.ts index 63828ef148..7ab79976b0 100644 --- a/backend/src/controllers/v1/integrationController.ts +++ b/backend/src/controllers/v1/integrationController.ts @@ -2,7 +2,7 @@ import { Request, Response } from "express"; import { Types } from "mongoose"; import { Integration } from "../../models"; import { EventService } from "../../services"; -import { eventPushSecrets } from "../../events"; +import { eventPushSecrets, eventStartIntegration } from "../../events"; import Folder from "../../models/folder"; import { getFolderByPath } from "../../services/FolderService"; import { BadRequestError } from "../../utils/errors"; @@ -27,19 +27,19 @@ export const createIntegration = async (req: Request, res: Response) => { owner, path, region, - secretPath, + secretPath } = req.body; const folders = await Folder.findOne({ workspace: req.integrationAuth.workspace._id, - environment: sourceEnvironment, + environment: sourceEnvironment }); if (folders) { const folder = getFolderByPath(folders.nodes, secretPath); if (!folder) { throw BadRequestError({ - message: "Path for service token does not exist", + message: "Path for service token does not exist" }); } } @@ -62,21 +62,21 @@ export const createIntegration = async (req: Request, res: Response) => { region, secretPath, integration: req.integrationAuth.integration, - integrationAuth: new Types.ObjectId(integrationAuthId), + integrationAuth: new Types.ObjectId(integrationAuthId) }).save(); if (integration) { // trigger event - push secrets EventService.handleEvent({ - event: eventPushSecrets({ + event: eventStartIntegration({ workspaceId: integration.workspace, - environment: sourceEnvironment, - }), + environment: sourceEnvironment + }) }); } return res.status(200).send({ - integration, + integration }); }; @@ -97,26 +97,26 @@ export const updateIntegration = async (req: Request, res: Response) => { appId, targetEnvironment, owner, // github-specific integration param - secretPath, + secretPath } = req.body; const folders = await Folder.findOne({ workspace: req.integration.workspace, - environment, + environment }); if (folders) { const folder = getFolderByPath(folders.nodes, secretPath); if (!folder) { throw BadRequestError({ - message: "Path for service token does not exist", + message: "Path for service token does not exist" }); } } const integration = await Integration.findOneAndUpdate( { - _id: req.integration._id, + _id: req.integration._id }, { environment, @@ -125,25 +125,25 @@ export const updateIntegration = async (req: Request, res: Response) => { appId, targetEnvironment, owner, - secretPath, + secretPath }, { - new: true, + new: true } ); if (integration) { // trigger event - push secrets EventService.handleEvent({ - event: eventPushSecrets({ + event: eventStartIntegration({ workspaceId: integration.workspace, - environment, - }), + environment + }) }); } return res.status(200).send({ - integration, + integration }); }; @@ -158,12 +158,12 @@ export const deleteIntegration = async (req: Request, res: Response) => { const { integrationId } = req.params; const integration = await Integration.findOneAndDelete({ - _id: integrationId, + _id: integrationId }); if (!integration) throw new Error("Failed to find integration"); return res.status(200).send({ - integration, + integration }); }; diff --git a/backend/src/controllers/v1/secretController.ts b/backend/src/controllers/v1/secretController.ts index bcb00d2096..cda7b5576b 100644 --- a/backend/src/controllers/v1/secretController.ts +++ b/backend/src/controllers/v1/secretController.ts @@ -80,7 +80,8 @@ export const pushSecrets = async (req: Request, res: Response) => { EventService.handleEvent({ event: eventPushSecrets({ workspaceId: new Types.ObjectId(workspaceId), - environment + environment, + secretPath: "/" }) }); diff --git a/backend/src/controllers/v1/webhookController.ts b/backend/src/controllers/v1/webhookController.ts new file mode 100644 index 0000000000..c4957fffeb --- /dev/null +++ b/backend/src/controllers/v1/webhookController.ts @@ -0,0 +1,140 @@ +import { Request, Response } from "express"; +import { Types } from "mongoose"; +import { client, getRootEncryptionKey } from "../../config"; +import { validateMembership } from "../../helpers"; +import Webhook from "../../models/webhooks"; +import { getWebhookPayload, triggerWebhookRequest } from "../../services/WebhookService"; +import { BadRequestError } from "../../utils/errors"; +import { ADMIN, ALGORITHM_AES_256_GCM, ENCODING_SCHEME_BASE64, MEMBER } from "../../variables"; + +export const createWebhook = async (req: Request, res: Response) => { + const { webhookUrl, webhookSecretKey, environment, workspaceId, secretPath } = req.body; + const webhook = new Webhook({ + workspace: workspaceId, + environment, + secretPath, + url: webhookUrl, + algorithm: ALGORITHM_AES_256_GCM, + keyEncoding: ENCODING_SCHEME_BASE64 + }); + + if (webhookSecretKey) { + const rootEncryptionKey = await getRootEncryptionKey(); + const { ciphertext, iv, tag } = client.encryptSymmetric(webhookSecretKey, rootEncryptionKey); + webhook.iv = iv; + webhook.tag = tag; + webhook.encryptedSecretKey = ciphertext; + } + + await webhook.save(); + + return res.status(200).send({ + webhook, + message: "successfully created webhook" + }); +}; + +export const updateWebhook = async (req: Request, res: Response) => { + const { webhookId } = req.params; + const { isDisabled } = req.body; + const webhook = await Webhook.findById(webhookId); + if (!webhook) { + throw BadRequestError({ message: "Webhook not found!!" }); + } + + // check that user is a member of the workspace + await validateMembership({ + userId: req.user._id.toString(), + workspaceId: webhook.workspace, + acceptedRoles: [ADMIN, MEMBER] + }); + + if (typeof isDisabled !== undefined) { + webhook.isDisabled = isDisabled; + } + await webhook.save(); + + return res.status(200).send({ + webhook, + message: "successfully updated webhook" + }); +}; + +export const deleteWebhook = async (req: Request, res: Response) => { + const { webhookId } = req.params; + const webhook = await Webhook.findById(webhookId); + if (!webhook) { + throw BadRequestError({ message: "Webhook not found!!" }); + } + + await validateMembership({ + userId: req.user._id.toString(), + workspaceId: webhook.workspace, + acceptedRoles: [ADMIN, MEMBER] + }); + await webhook.remove(); + + return res.status(200).send({ + message: "successfully removed webhook" + }); +}; + +export const testWebhook = async (req: Request, res: Response) => { + const { webhookId } = req.params; + const webhook = await Webhook.findById(webhookId); + if (!webhook) { + throw BadRequestError({ message: "Webhook not found!!" }); + } + + await validateMembership({ + userId: req.user._id.toString(), + workspaceId: webhook.workspace, + acceptedRoles: [ADMIN, MEMBER] + }); + + try { + await triggerWebhookRequest( + webhook, + getWebhookPayload( + "test", + webhook.workspace.toString(), + webhook.environment, + webhook.secretPath + ) + ); + await Webhook.findByIdAndUpdate(webhookId, { + lastStatus: "success", + lastRunErrorMessage: null + }); + } catch (err) { + await Webhook.findByIdAndUpdate(webhookId, { + lastStatus: "failed", + lastRunErrorMessage: (err as Error).message + }); + return res.status(400).send({ + message: "Failed to receive response", + error: (err as Error).message + }); + } + + return res.status(200).send({ + message: "Successfully received response" + }); +}; + +export const listWebhooks = async (req: Request, res: Response) => { + const { environment, workspaceId, secretPath } = req.query; + + const optionalFilters: Record = {}; + if (environment) optionalFilters.environment = environment as string; + if (secretPath) optionalFilters.secretPath = secretPath as string; + + const webhooks = await Webhook.find({ + workspace: new Types.ObjectId(workspaceId as string), + ...optionalFilters + }); + + return res.status(200).send({ + webhooks + }); +}; diff --git a/backend/src/controllers/v2/secretsController.ts b/backend/src/controllers/v2/secretsController.ts index b93846e70c..cc1aae0553 100644 --- a/backend/src/controllers/v2/secretsController.ts +++ b/backend/src/controllers/v2/secretsController.ts @@ -30,9 +30,11 @@ import Folder from "../../models/folder"; import { getFolderByPath, getFolderIdFromServiceToken, - searchByFolderId + searchByFolderId, + searchByFolderIdWithDir } from "../../services/FolderService"; import { isValidScope } from "../../helpers/secrets"; +import path from "path"; /** * Peform a batch of any specified CUD secret operations @@ -47,14 +49,13 @@ export const batchSecrets = async (req: Request, res: Response) => { const { workspaceId, environment, - requests, - secretPath + requests }: { workspaceId: string; environment: string; requests: BatchSecretRequest[]; - secretPath: string; } = req.body; + let secretPath = req.body.secretPath as string; let folderId = req.body.folderId as string; const createSecrets: BatchSecret[] = []; @@ -68,10 +69,6 @@ export const batchSecrets = async (req: Request, res: Response) => { }); const folders = await Folder.findOne({ workspace: workspaceId, environment }); - if (folders && folderId !== "root") { - const folder = searchByFolderId(folders.nodes, folderId as string); - if (!folder) throw BadRequestError({ message: "Folder not found" }); - } if (req.authData.authPayload instanceof ServiceTokenData) { const isValidScopeAccess = isValidScope(req.authData.authPayload, environment, secretPath); @@ -87,6 +84,15 @@ export const batchSecrets = async (req: Request, res: Response) => { folderId = await getFolderIdFromServiceToken(workspaceId, environment, secretPath); } + if (folders && folderId !== "root") { + const folder = searchByFolderIdWithDir(folders.nodes, folderId as string); + if (!folder?.folder) throw BadRequestError({ message: "Folder not found" }); + secretPath = path.join( + "/", + ...folder.dir.map(({ name }) => name).filter((name) => name !== "root") + ); + } + for await (const request of requests) { // do a validation @@ -319,7 +325,10 @@ export const batchSecrets = async (req: Request, res: Response) => { // // trigger event - push secrets await EventService.handleEvent({ event: eventPushSecrets({ - workspaceId: new Types.ObjectId(workspaceId) + workspaceId: new Types.ObjectId(workspaceId), + environment, + // root condition else this will be filled according to the path or folderid + secretPath: secretPath || "/" }) }); @@ -535,7 +544,9 @@ export const createSecrets = async (req: Request, res: Response) => { // trigger event - push secrets await EventService.handleEvent({ event: eventPushSecrets({ - workspaceId: new Types.ObjectId(workspaceId) + workspaceId: new Types.ObjectId(workspaceId), + environment, + secretPath: secretPath || "/" }) }); }, 5000); @@ -1033,13 +1044,16 @@ export const updateSecrets = async (req: Request, res: Response) => { Object.keys(workspaceSecretObj).forEach(async (key) => { // trigger event - push secrets - setTimeout(async () => { - await EventService.handleEvent({ - event: eventPushSecrets({ - workspaceId: new Types.ObjectId(key) - }) - }); - }, 10000); + // This route is not used anymore thus keep it commented out as it does not expose environment + // it will end up creating a lot of requests from the server + // setTimeout(async () => { + // await EventService.handleEvent({ + // event: eventPushSecrets({ + // workspaceId: new Types.ObjectId(key), + // environment, + // }) + // }); + // }, 10000); const updateAction = await EELogService.createAction({ name: ACTION_UPDATE_SECRETS, @@ -1174,11 +1188,13 @@ export const deleteSecrets = async (req: Request, res: Response) => { Object.keys(workspaceSecretObj).forEach(async (key) => { // trigger event - push secrets - await EventService.handleEvent({ - event: eventPushSecrets({ - workspaceId: new Types.ObjectId(key) - }) - }); + // DEPRECIATED(akhilmhdh): as this would cause server to send so many request + // and this route is not used anymore thus like snapshot keeping it commented out + // await EventService.handleEvent({ + // event: eventPushSecrets({ + // workspaceId: new Types.ObjectId(key) + // }) + // }); const deleteAction = await EELogService.createAction({ name: ACTION_DELETE_SECRETS, userId: req.user?._id, diff --git a/backend/src/controllers/v2/workspaceController.ts b/backend/src/controllers/v2/workspaceController.ts index c0d46f8514..b90b1a47b6 100644 --- a/backend/src/controllers/v2/workspaceController.ts +++ b/backend/src/controllers/v2/workspaceController.ts @@ -1,34 +1,29 @@ import { Request, Response } from "express"; import { Types } from "mongoose"; +import { Key, Membership, ServiceTokenData, Workspace } from "../../models"; import { - Key, - Membership, - ServiceTokenData, - Workspace, -} from "../../models"; -import { - pullSecrets as pull, - v2PushSecrets as push, - reformatPullSecrets, + pullSecrets as pull, + v2PushSecrets as push, + reformatPullSecrets } from "../../helpers/secret"; import { pushKeys } from "../../helpers/key"; import { EventService, TelemetryService } from "../../services"; import { eventPushSecrets } from "../../events"; interface V2PushSecret { - type: string; // personal or shared - secretKeyCiphertext: string; - secretKeyIV: string; - secretKeyTag: string; - secretKeyHash: string; - secretValueCiphertext: string; - secretValueIV: string; - secretValueTag: string; - secretValueHash: string; - secretCommentCiphertext?: string; - secretCommentIV?: string; - secretCommentTag?: string; - secretCommentHash?: string; + type: string; // personal or shared + secretKeyCiphertext: string; + secretKeyIV: string; + secretKeyTag: string; + secretKeyHash: string; + secretValueCiphertext: string; + secretValueIV: string; + secretValueTag: string; + secretValueHash: string; + secretCommentCiphertext?: string; + secretCommentIV?: string; + secretCommentTag?: string; + secretCommentHash?: string; } /** @@ -39,7 +34,7 @@ interface V2PushSecret { * @returns */ export const pushWorkspaceSecrets = async (req: Request, res: Response) => { - // upload (encrypted) secrets to workspace with id [workspaceId] + // upload (encrypted) secrets to workspace with id [workspaceId] const postHogClient = await TelemetryService.getPostHogClient(); let { secrets }: { secrets: V2PushSecret[] } = req.body; const { keys, environment, channel } = req.body; @@ -62,13 +57,13 @@ export const pushWorkspaceSecrets = async (req: Request, res: Response) => { environment, secrets, channel: channel ? channel : "cli", - ipAddress: req.realIP, + ipAddress: req.realIP }); await pushKeys({ userId: req.user._id, workspaceId, - keys, + keys }); if (postHogClient) { @@ -79,8 +74,8 @@ export const pushWorkspaceSecrets = async (req: Request, res: Response) => { numberOfSecrets: secrets.length, environment, workspaceId, - channel: channel ? channel : "cli", - }, + channel: channel ? channel : "cli" + } }); } @@ -89,12 +84,13 @@ export const pushWorkspaceSecrets = async (req: Request, res: Response) => { event: eventPushSecrets({ workspaceId: new Types.ObjectId(workspaceId), environment, - }), + secretPath: "/" + }) }); - return res.status(200).send({ - message: "Successfully uploaded workspace secrets", - }); + return res.status(200).send({ + message: "Successfully uploaded workspace secrets" + }); }; /** @@ -105,7 +101,7 @@ export const pushWorkspaceSecrets = async (req: Request, res: Response) => { * @returns */ export const pullSecrets = async (req: Request, res: Response) => { - let secrets; + let secrets; const postHogClient = await TelemetryService.getPostHogClient(); const environment: string = req.query.environment as string; const channel: string = req.query.channel as string; @@ -128,7 +124,7 @@ export const pullSecrets = async (req: Request, res: Response) => { workspaceId, environment, channel: channel ? channel : "cli", - ipAddress: req.realIP, + ipAddress: req.realIP }); if (channel !== "cli") { @@ -144,18 +140,18 @@ export const pullSecrets = async (req: Request, res: Response) => { numberOfSecrets: secrets.length, environment, workspaceId, - channel: channel ? channel : "cli", - }, + channel: channel ? channel : "cli" + } }); } - return res.status(200).send({ - secrets, - }); + return res.status(200).send({ + secrets + }); }; export const getWorkspaceKey = async (req: Request, res: Response) => { - /* + /* #swagger.summary = 'Return encrypted project key' #swagger.description = 'Return encrypted project key' @@ -183,43 +179,38 @@ export const getWorkspaceKey = async (req: Request, res: Response) => { } } */ - let key; + let key; const { workspaceId } = req.params; key = await Key.findOne({ workspace: workspaceId, - receiver: req.user._id, + receiver: req.user._id }).populate("sender", "+publicKey"); if (!key) throw new Error("Failed to find workspace key"); - return res.status(200).json(key); -} -export const getWorkspaceServiceTokenData = async ( - req: Request, - res: Response -) => { + return res.status(200).json(key); +}; +export const getWorkspaceServiceTokenData = async (req: Request, res: Response) => { const { workspaceId } = req.params; - const serviceTokenData = await ServiceTokenData - .find({ - workspace: workspaceId, - }) - .select("+encryptedKey +iv +tag"); + const serviceTokenData = await ServiceTokenData.find({ + workspace: workspaceId + }).select("+encryptedKey +iv +tag"); - return res.status(200).send({ - serviceTokenData, - }); -} + return res.status(200).send({ + serviceTokenData + }); +}; /** * Return memberships for workspace with id [workspaceId] - * @param req - * @param res - * @returns + * @param req + * @param res + * @returns */ export const getWorkspaceMemberships = async (req: Request, res: Response) => { - /* + /* #swagger.summary = 'Return project memberships' #swagger.description = 'Return project memberships' @@ -255,22 +246,22 @@ export const getWorkspaceMemberships = async (req: Request, res: Response) => { const { workspaceId } = req.params; const memberships = await Membership.find({ - workspace: workspaceId, + workspace: workspaceId }).populate("user", "+publicKey"); - return res.status(200).send({ - memberships, - }); -} + return res.status(200).send({ + memberships + }); +}; /** * Update role of membership with id [membershipId] to role [role] - * @param req - * @param res - * @returns + * @param req + * @param res + * @returns */ export const updateWorkspaceMembership = async (req: Request, res: Response) => { - /* + /* #swagger.summary = 'Update project membership' #swagger.description = 'Update project membership' @@ -323,33 +314,32 @@ export const updateWorkspaceMembership = async (req: Request, res: Response) => } } */ - const { - membershipId, - } = req.params; + const { membershipId } = req.params; const { role } = req.body; - + const membership = await Membership.findByIdAndUpdate( membershipId, { - role, - }, { - new: true, + role + }, + { + new: true } ); - return res.status(200).send({ - membership, - }); -} + return res.status(200).send({ + membership + }); +}; /** * Delete workspace membership with id [membershipId] - * @param req - * @param res - * @returns + * @param req + * @param res + * @returns */ export const deleteWorkspaceMembership = async (req: Request, res: Response) => { - /* + /* #swagger.summary = 'Delete project membership' #swagger.description = 'Delete project membership' @@ -385,23 +375,21 @@ export const deleteWorkspaceMembership = async (req: Request, res: Response) => } } */ - const { - membershipId, - } = req.params; - + const { membershipId } = req.params; + const membership = await Membership.findByIdAndDelete(membershipId); - + if (!membership) throw new Error("Failed to delete workspace membership"); - + await Key.deleteMany({ receiver: membership.user, - workspace: membership.workspace, + workspace: membership.workspace }); - - return res.status(200).send({ - membership, - }); -} + + return res.status(200).send({ + membership + }); +}; /** * Change autoCapitilzation Rule of workspace @@ -415,18 +403,18 @@ export const toggleAutoCapitalization = async (req: Request, res: Response) => { const workspace = await Workspace.findOneAndUpdate( { - _id: workspaceId, + _id: workspaceId }, { - autoCapitalization, + autoCapitalization }, { - new: true, + new: true } ); - return res.status(200).send({ - message: "Successfully changed autoCapitalization setting", - workspace, - }); + return res.status(200).send({ + message: "Successfully changed autoCapitalization setting", + workspace + }); }; diff --git a/backend/src/controllers/v3/secretsController.ts b/backend/src/controllers/v3/secretsController.ts index 3c7336241a..e1edd95ad1 100644 --- a/backend/src/controllers/v3/secretsController.ts +++ b/backend/src/controllers/v3/secretsController.ts @@ -21,22 +21,22 @@ export const getSecretsRaw = async (req: Request, res: Response) => { workspaceId: new Types.ObjectId(workspaceId), environment, secretPath, - authData: req.authData, + authData: req.authData }); const key = await BotService.getWorkspaceKeyWithBot({ - workspaceId: new Types.ObjectId(workspaceId), + workspaceId: new Types.ObjectId(workspaceId) }); return res.status(200).send({ secrets: secrets.map((secret) => { const rep = repackageSecretToRaw({ secret, - key, + key }); return rep; - }), + }) }); }; @@ -58,54 +58,47 @@ export const getSecretByNameRaw = async (req: Request, res: Response) => { environment, type, secretPath, - authData: req.authData, + authData: req.authData }); const key = await BotService.getWorkspaceKeyWithBot({ - workspaceId: new Types.ObjectId(workspaceId), + workspaceId: new Types.ObjectId(workspaceId) }); return res.status(200).send({ secret: repackageSecretToRaw({ secret, - key, - }), + key + }) }); }; /** * Create secret with name [secretName] in plaintext * @param req - * @param res + * @param res */ export const createSecretRaw = async (req: Request, res: Response) => { const { secretName } = req.params; - const { - workspaceId, - environment, - type, - secretValue, - secretComment, - secretPath = "/", - } = req.body; + const { workspaceId, environment, type, secretValue, secretComment, secretPath = "/" } = req.body; const key = await BotService.getWorkspaceKeyWithBot({ - workspaceId: new Types.ObjectId(workspaceId), + workspaceId: new Types.ObjectId(workspaceId) }); const secretKeyEncrypted = encryptSymmetric128BitHexKeyUTF8({ plaintext: secretName, - key, + key }); const secretValueEncrypted = encryptSymmetric128BitHexKeyUTF8({ plaintext: secretValue, - key, + key }); const secretCommentEncrypted = encryptSymmetric128BitHexKeyUTF8({ plaintext: secretComment, - key, + key }); const secret = await SecretService.createSecret({ @@ -123,14 +116,15 @@ export const createSecretRaw = async (req: Request, res: Response) => { secretPath, secretCommentCiphertext: secretCommentEncrypted.ciphertext, secretCommentIV: secretCommentEncrypted.iv, - secretCommentTag: secretCommentEncrypted.tag, + secretCommentTag: secretCommentEncrypted.tag }); await EventService.handleEvent({ event: eventPushSecrets({ workspaceId: new Types.ObjectId(workspaceId), environment, - }), + secretPath + }) }); const secretWithoutBlindIndex = secret.toObject(); @@ -139,10 +133,10 @@ export const createSecretRaw = async (req: Request, res: Response) => { return res.status(200).send({ secret: repackageSecretToRaw({ secret: secretWithoutBlindIndex, - key, - }), + key + }) }); -} +}; /** * Update secret with name [secretName] @@ -151,21 +145,15 @@ export const createSecretRaw = async (req: Request, res: Response) => { */ export const updateSecretByNameRaw = async (req: Request, res: Response) => { const { secretName } = req.params; - const { - workspaceId, - environment, - type, - secretValue, - secretPath = "/", - } = req.body; + const { workspaceId, environment, type, secretValue, secretPath = "/" } = req.body; const key = await BotService.getWorkspaceKeyWithBot({ - workspaceId: new Types.ObjectId(workspaceId), + workspaceId: new Types.ObjectId(workspaceId) }); const secretValueEncrypted = encryptSymmetric128BitHexKeyUTF8({ plaintext: secretValue, - key, + key }); const secret = await SecretService.updateSecret({ @@ -177,21 +165,22 @@ export const updateSecretByNameRaw = async (req: Request, res: Response) => { secretValueCiphertext: secretValueEncrypted.ciphertext, secretValueIV: secretValueEncrypted.iv, secretValueTag: secretValueEncrypted.tag, - secretPath, + secretPath }); await EventService.handleEvent({ event: eventPushSecrets({ workspaceId: new Types.ObjectId(workspaceId), environment, - }), + secretPath + }) }); return res.status(200).send({ secret: repackageSecretToRaw({ secret, - key, - }), + key + }) }); }; @@ -202,12 +191,7 @@ export const updateSecretByNameRaw = async (req: Request, res: Response) => { */ export const deleteSecretByNameRaw = async (req: Request, res: Response) => { const { secretName } = req.params; - const { - workspaceId, - environment, - type, - secretPath = "/", - } = req.body; + const { workspaceId, environment, type, secretPath = "/" } = req.body; const { secret } = await SecretService.deleteSecret({ secretName, @@ -215,25 +199,26 @@ export const deleteSecretByNameRaw = async (req: Request, res: Response) => { environment, type, authData: req.authData, - secretPath, + secretPath }); await EventService.handleEvent({ event: eventPushSecrets({ workspaceId: new Types.ObjectId(workspaceId), environment, - }), + secretPath + }) }); const key = await BotService.getWorkspaceKeyWithBot({ - workspaceId: new Types.ObjectId(workspaceId), + workspaceId: new Types.ObjectId(workspaceId) }); return res.status(200).send({ secret: repackageSecretToRaw({ secret, - key, - }), + key + }) }); }; @@ -252,11 +237,11 @@ export const getSecrets = async (req: Request, res: Response) => { workspaceId: new Types.ObjectId(workspaceId), environment, secretPath, - authData: req.authData, + authData: req.authData }); return res.status(200).send({ - secrets, + secrets }); }; @@ -278,11 +263,11 @@ export const getSecretByName = async (req: Request, res: Response) => { environment, type, secretPath, - authData: req.authData, + authData: req.authData }); return res.status(200).send({ - secret, + secret }); }; @@ -306,7 +291,7 @@ export const createSecret = async (req: Request, res: Response) => { secretCommentCiphertext, secretCommentIV, secretCommentTag, - secretPath = "/", + secretPath = "/" } = req.body; const secret = await SecretService.createSecret({ @@ -324,25 +309,25 @@ export const createSecret = async (req: Request, res: Response) => { secretPath, secretCommentCiphertext, secretCommentIV, - secretCommentTag, + secretCommentTag }); await EventService.handleEvent({ event: eventPushSecrets({ workspaceId: new Types.ObjectId(workspaceId), environment, - }), + secretPath + }) }); const secretWithoutBlindIndex = secret.toObject(); delete secretWithoutBlindIndex.secretBlindIndex; return res.status(200).send({ - secret: secretWithoutBlindIndex, + secret: secretWithoutBlindIndex }); }; - /** * Update secret with name [secretName] * @param req @@ -357,7 +342,7 @@ export const updateSecretByName = async (req: Request, res: Response) => { secretValueCiphertext, secretValueIV, secretValueTag, - secretPath = "/", + secretPath = "/" } = req.body; const secret = await SecretService.updateSecret({ @@ -369,18 +354,19 @@ export const updateSecretByName = async (req: Request, res: Response) => { secretValueCiphertext, secretValueIV, secretValueTag, - secretPath, + secretPath }); await EventService.handleEvent({ event: eventPushSecrets({ workspaceId: new Types.ObjectId(workspaceId), environment, - }), + secretPath + }) }); return res.status(200).send({ - secret, + secret }); }; @@ -391,12 +377,7 @@ export const updateSecretByName = async (req: Request, res: Response) => { */ export const deleteSecretByName = async (req: Request, res: Response) => { const { secretName } = req.params; - const { - workspaceId, - environment, - type, - secretPath = "/", - } = req.body; + const { workspaceId, environment, type, secretPath = "/" } = req.body; const { secret } = await SecretService.deleteSecret({ secretName, @@ -404,17 +385,18 @@ export const deleteSecretByName = async (req: Request, res: Response) => { environment, type, authData: req.authData, - secretPath, + secretPath }); await EventService.handleEvent({ event: eventPushSecrets({ workspaceId: new Types.ObjectId(workspaceId), environment, - }), + secretPath + }) }); return res.status(200).send({ - secret, + secret }); }; diff --git a/backend/src/events/index.ts b/backend/src/events/index.ts index d8198d2fb3..ac9ad176de 100644 --- a/backend/src/events/index.ts +++ b/backend/src/events/index.ts @@ -1,5 +1,4 @@ -import { eventPushSecrets } from "./secret" +import { eventPushSecrets } from "./secret"; +import { eventStartIntegration } from "./integration"; -export { - eventPushSecrets, -} \ No newline at end of file +export { eventPushSecrets, eventStartIntegration }; diff --git a/backend/src/events/integration.ts b/backend/src/events/integration.ts new file mode 100644 index 0000000000..746858e46e --- /dev/null +++ b/backend/src/events/integration.ts @@ -0,0 +1,23 @@ +import { Types } from "mongoose"; +import { EVENT_START_INTEGRATION } from "../variables"; + +/* + * Return event for starting integrations + * @param {Object} obj + * @param {String} obj.workspaceId - id of workspace to push secrets to + * @returns + */ +export const eventStartIntegration = ({ + workspaceId, + environment +}: { + workspaceId: Types.ObjectId; + environment: string; +}) => { + return { + name: EVENT_START_INTEGRATION, + workspaceId, + environment, + payload: {} + }; +}; diff --git a/backend/src/events/secret.ts b/backend/src/events/secret.ts index 23ff9f59cb..894e3300d4 100644 --- a/backend/src/events/secret.ts +++ b/backend/src/events/secret.ts @@ -1,64 +1,54 @@ import { Types } from "mongoose"; -import { - EVENT_PULL_SECRETS, - EVENT_PUSH_SECRETS, -} from "../variables"; +import { EVENT_PULL_SECRETS, 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"; + 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 + * @returns */ const eventPushSecrets = ({ + workspaceId, + environment, + secretPath +}: { + workspaceId: Types.ObjectId; + environment: string; + secretPath: string; +}) => { + return { + name: EVENT_PUSH_SECRETS, workspaceId, environment, -}: { - workspaceId: Types.ObjectId; - environment?: string; -}) => { - return ({ - name: EVENT_PUSH_SECRETS, - workspaceId, - environment, - payload: { - - }, - }); -} + secretPath, + payload: {} + }; +}; /** * Return event for pulling secrets * @param {Object} obj * @param {String} obj.workspaceId - id of workspace to pull secrets from - * @returns + * @returns */ -const eventPullSecrets = ({ +const eventPullSecrets = ({ workspaceId }: { workspaceId: string }) => { + return { + name: EVENT_PULL_SECRETS, workspaceId, -}: { - workspaceId: string; -}) => { - return ({ - name: EVENT_PULL_SECRETS, - workspaceId, - payload: { + payload: {} + }; +}; - }, - }); -} - -export { - eventPushSecrets, -} +export { eventPushSecrets }; diff --git a/backend/src/helpers/event.ts b/backend/src/helpers/event.ts index 124da257c9..03089d36e2 100644 --- a/backend/src/helpers/event.ts +++ b/backend/src/helpers/event.ts @@ -1,12 +1,14 @@ import { Types } from "mongoose"; import { Bot } from "../models"; -import { EVENT_PUSH_SECRETS } from "../variables"; +import { EVENT_PUSH_SECRETS, EVENT_START_INTEGRATION } from "../variables"; import { IntegrationService } from "../services"; +import { triggerWebhook } from "../services/WebhookService"; interface Event { name: string; workspaceId: Types.ObjectId; environment?: string; + secretPath?: string; payload: any; } @@ -19,22 +21,32 @@ interface Event { * @param {Object} obj.event.payload - payload of event (depends on event) */ export const handleEventHelper = async ({ event }: { event: Event }) => { - const { workspaceId, environment } = event; + const { workspaceId, environment, secretPath } = event; // TODO: moduralize bot check into separate function const bot = await Bot.findOne({ workspace: workspaceId, - isActive: true, + isActive: true }); - if (!bot) return; - switch (event.name) { case EVENT_PUSH_SECRETS: - IntegrationService.syncIntegrations({ - workspaceId, - environment, - }); + if (bot) { + await IntegrationService.syncIntegrations({ + workspaceId, + environment + }); + } + console.log("-------", workspaceId, environment, secretPath); + triggerWebhook(workspaceId.toString(), environment || "", secretPath || ""); + break; + case EVENT_START_INTEGRATION: + if (bot) { + IntegrationService.syncIntegrations({ + workspaceId, + environment + }); + } break; } -}; \ No newline at end of file +}; diff --git a/backend/src/index.ts b/backend/src/index.ts index 851d151aa0..9733c1fdb4 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -20,7 +20,7 @@ import { organizations as eeOrganizationsRouter, secret as eeSecretRouter, secretSnapshot as eeSecretSnapshotRouter, - workspace as eeWorkspaceRouter, + workspace as eeWorkspaceRouter } from "./ee/routes/v1"; import { auth as v1AuthRouter, @@ -41,6 +41,7 @@ import { userAction as v1UserActionRouter, user as v1UserRouter, workspace as v1WorkspaceRouter, + webhooks as v1WebhooksRouter } from "./routes/v1"; import { auth as v2AuthRouter, @@ -53,13 +54,13 @@ import { serviceTokenData as v2ServiceTokenDataRouter, serviceAccounts as v2ServiceAccountsRouter, environment as v2EnvironmentRouter, - tags as v2TagsRouter, + tags as v2TagsRouter } from "./routes/v2"; import { auth as v3AuthRouter, secrets as v3SecretsRouter, signup as v3SignupRouter, - workspaces as v3WorkspacesRouter, + workspaces as v3WorkspacesRouter } from "./routes/v3"; import { healthCheck } from "./routes/status"; import { getLogger } from "./utils/logger"; @@ -80,7 +81,7 @@ const main = async () => { app.use( cors({ credentials: true, - origin: await getSiteURL(), + origin: await getSiteURL() }) ); @@ -126,6 +127,7 @@ const main = async () => { app.use("/api/v1/integration-auth", v1IntegrationAuthRouter); app.use("/api/v1/folders", v1SecretsFolder); app.use("/api/v1/secret-scanning", v1SecretScanningRouter); + app.use("/api/v1/webhooks", v1WebhooksRouter); // v2 routes (improvements) app.use("/api/v2/signup", v2SignupRouter); @@ -157,7 +159,7 @@ const main = async () => { if (res.headersSent) return next(); next( RouteNotFoundError({ - message: `The requested source '(${req.method})${req.url}' was not found`, + message: `The requested source '(${req.method})${req.url}' was not found` }) ); }); @@ -165,9 +167,7 @@ const main = async () => { app.use(requestErrorHandler); const server = app.listen(await getPort(), async () => { - (await getLogger("backend-main")).info( - `Server started listening at port ${await getPort()}` - ); + (await getLogger("backend-main")).info(`Server started listening at port ${await getPort()}`); }); // await createTestUserForDevelopment(); diff --git a/backend/src/models/webhooks.ts b/backend/src/models/webhooks.ts new file mode 100644 index 0000000000..b4a168878f --- /dev/null +++ b/backend/src/models/webhooks.ts @@ -0,0 +1,85 @@ +import { Document, Schema, Types, model } from "mongoose"; +import { ALGORITHM_AES_256_GCM, ENCODING_SCHEME_BASE64, ENCODING_SCHEME_UTF8 } from "../variables"; + +export interface IWebhook extends Document { + _id: Types.ObjectId; + workspace: Types.ObjectId; + environment: string; + secretPath: string; + url: string; + lastStatus: "success" | "failed"; + lastRunErrorMessage?: string; + isDisabled: boolean; + encryptedSecretKey: string; + iv: string; + tag: string; + algorithm: "aes-256-gcm"; + keyEncoding: "base64" | "utf8"; +} + +const WebhookSchema = new Schema( + { + workspace: { + type: Schema.Types.ObjectId, + ref: "Workspace", + required: true + }, + environment: { + type: String, + required: true + }, + secretPath: { + type: String, + required: true, + default: "/" + }, + url: { + type: String, + required: true + }, + lastStatus: { + type: String, + enum: ["success", "failed"] + }, + lastRunErrorMessage: { + type: String + }, + isDisabled: { + type: Boolean, + default: false + }, + // used for webhook signature + encryptedSecretKey: { + type: String, + select: false + }, + iv: { + type: String, + select: false + }, + tag: { + type: String, + select: false + }, + algorithm: { + // the encryption algorithm used + type: String, + enum: [ALGORITHM_AES_256_GCM], + required: true, + select: false + }, + keyEncoding: { + type: String, + enum: [ENCODING_SCHEME_UTF8, ENCODING_SCHEME_BASE64], + required: true, + select: false + } + }, + { + timestamps: true + } +); + +const Webhook = model("Webhook", WebhookSchema); + +export default Webhook; diff --git a/backend/src/routes/v1/index.ts b/backend/src/routes/v1/index.ts index a1015c0910..b9f26d32df 100644 --- a/backend/src/routes/v1/index.ts +++ b/backend/src/routes/v1/index.ts @@ -16,24 +16,26 @@ import integration from "./integration"; import integrationAuth from "./integrationAuth"; import secretsFolder from "./secretsFolder"; import secretScanning from "./secretScanning"; +import webhooks from "./webhook"; export { - signup, - auth, - bot, - user, - userAction, - organization, - workspace, - membershipOrg, - membership, - key, - inviteOrg, - secret, - serviceToken, - password, - integration, - integrationAuth, - secretsFolder, - secretScanning + signup, + auth, + bot, + user, + userAction, + organization, + workspace, + membershipOrg, + membership, + key, + inviteOrg, + secret, + serviceToken, + password, + integration, + integrationAuth, + secretsFolder, + secretScanning, + webhooks }; diff --git a/backend/src/routes/v1/webhook.ts b/backend/src/routes/v1/webhook.ts new file mode 100644 index 0000000000..2091fa4471 --- /dev/null +++ b/backend/src/routes/v1/webhook.ts @@ -0,0 +1,75 @@ +import express from "express"; +const router = express.Router(); +import { requireAuth, requireWorkspaceAuth, validateRequest } from "../../middleware"; +import { body, param, query } from "express-validator"; +import { ADMIN, AUTH_MODE_JWT, AUTH_MODE_SERVICE_ACCOUNT, MEMBER } from "../../variables"; +import { webhookController } from "../../controllers/v1"; + +router.post( + "/", + requireAuth({ + acceptedAuthModes: [AUTH_MODE_JWT] + }), + requireWorkspaceAuth({ + acceptedRoles: [ADMIN, MEMBER], + locationWorkspaceId: "body", + locationEnvironment: "body" + }), + body("workspaceId").exists().isString().trim(), + body("environment").exists().isString().trim(), + body("webhookUrl").exists().isString().isURL().trim(), + body("webhookSecretKey").isString().trim(), + body("secretPath").default("/").isString().trim(), + validateRequest, + webhookController.createWebhook +); + +router.patch( + "/:webhookId", + requireAuth({ + acceptedAuthModes: [AUTH_MODE_JWT] + }), + param("webhookId").exists().isString().trim(), + body("isDisabled").default(false).isBoolean(), + validateRequest, + webhookController.updateWebhook +); + +router.post( + "/:webhookId/test", + requireAuth({ + acceptedAuthModes: [AUTH_MODE_JWT] + }), + param("webhookId").exists().isString().trim(), + validateRequest, + webhookController.testWebhook +); + +router.delete( + "/:webhookId", + requireAuth({ + acceptedAuthModes: [AUTH_MODE_JWT] + }), + param("webhookId").exists().isString().trim(), + validateRequest, + webhookController.deleteWebhook +); + +router.get( + "/", + requireAuth({ + acceptedAuthModes: [AUTH_MODE_JWT] + }), + requireWorkspaceAuth({ + acceptedRoles: [ADMIN, MEMBER], + locationWorkspaceId: "query", + locationEnvironment: "query" + }), + query("workspaceId").exists().isString().trim(), + query("environment").optional().isString().trim(), + query("secretPath").optional().isString().trim(), + validateRequest, + webhookController.listWebhooks +); + +export default router; diff --git a/backend/src/routes/v2/secrets.ts b/backend/src/routes/v2/secrets.ts index cd550d99e0..731e58eb01 100644 --- a/backend/src/routes/v2/secrets.ts +++ b/backend/src/routes/v2/secrets.ts @@ -5,7 +5,7 @@ import { requireAuth, requireSecretsAuth, requireWorkspaceAuth, - validateRequest, + validateRequest } from "../../middleware"; import { validateClientForSecrets } from "../../validation"; import { body, query } from "express-validator"; @@ -20,22 +20,18 @@ import { PERMISSION_READ_SECRETS, PERMISSION_WRITE_SECRETS, SECRET_PERSONAL, - SECRET_SHARED, + SECRET_SHARED } from "../../variables"; import { BatchSecretRequest } from "../../types/secret"; router.post( "/batch", requireAuth({ - acceptedAuthModes: [ - AUTH_MODE_JWT, - AUTH_MODE_API_KEY, - AUTH_MODE_SERVICE_TOKEN, - ], + acceptedAuthModes: [AUTH_MODE_JWT, AUTH_MODE_API_KEY, AUTH_MODE_SERVICE_TOKEN] }), requireWorkspaceAuth({ acceptedRoles: [ADMIN, MEMBER], - locationWorkspaceId: "body", + locationWorkspaceId: "body" }), body("workspaceId").exists().isString().trim(), body("folderId").default("root").isString().trim(), @@ -52,10 +48,8 @@ router.post( if (secretIds.length > 0) { req.secrets = await validateClientForSecrets({ authData: req.authData, - secretIds: secretIds.map( - (secretId: string) => new Types.ObjectId(secretId) - ), - requiredPermissions: [], + secretIds: secretIds.map((secretId: string) => new Types.ObjectId(secretId)), + requiredPermissions: [] }); } } @@ -76,14 +70,11 @@ router.post( .custom((value) => { if (Array.isArray(value)) { // case: create multiple secrets - if (value.length === 0) - throw new Error("secrets cannot be an empty array"); + if (value.length === 0) throw new Error("secrets cannot be an empty array"); for (const secret of value) { if ( !secret.type || - !( - secret.type === SECRET_PERSONAL || secret.type === SECRET_SHARED - ) || + !(secret.type === SECRET_PERSONAL || secret.type === SECRET_SHARED) || !secret.secretKeyCiphertext || !secret.secretKeyIV || !secret.secretKeyTag || @@ -108,9 +99,7 @@ router.post( !value.secretValueIV || !value.secretValueTag ) { - throw new Error( - "secrets object is missing required secret properties" - ); + throw new Error("secrets object is missing required secret properties"); } } else { throw new Error("secrets must be an object or an array of objects"); @@ -120,17 +109,13 @@ router.post( }), validateRequest, requireAuth({ - acceptedAuthModes: [ - AUTH_MODE_JWT, - AUTH_MODE_API_KEY, - AUTH_MODE_SERVICE_TOKEN, - ], + acceptedAuthModes: [AUTH_MODE_JWT, AUTH_MODE_API_KEY, AUTH_MODE_SERVICE_TOKEN] }), requireWorkspaceAuth({ acceptedRoles: [ADMIN, MEMBER], locationWorkspaceId: "body", locationEnvironment: "body", - requiredPermissions: [PERMISSION_WRITE_SECRETS], + requiredPermissions: [PERMISSION_WRITE_SECRETS] }), secretsController.createSecrets ); @@ -148,14 +133,14 @@ router.get( AUTH_MODE_JWT, AUTH_MODE_API_KEY, AUTH_MODE_SERVICE_TOKEN, - AUTH_MODE_SERVICE_ACCOUNT, - ], + AUTH_MODE_SERVICE_ACCOUNT + ] }), requireWorkspaceAuth({ acceptedRoles: [ADMIN, MEMBER], locationWorkspaceId: "query", locationEnvironment: "query", - requiredPermissions: [PERMISSION_READ_SECRETS], + requiredPermissions: [PERMISSION_READ_SECRETS] }), secretsController.getSecrets ); @@ -167,8 +152,7 @@ router.patch( .custom((value) => { if (Array.isArray(value)) { // case: update multiple secrets - if (value.length === 0) - throw new Error("secrets cannot be an empty array"); + if (value.length === 0) throw new Error("secrets cannot be an empty array"); for (const secret of value) { if (!secret.id) { throw new Error("Each secret must contain a ID property"); @@ -187,15 +171,11 @@ router.patch( }), validateRequest, requireAuth({ - acceptedAuthModes: [ - AUTH_MODE_JWT, - AUTH_MODE_API_KEY, - AUTH_MODE_SERVICE_TOKEN, - ], + acceptedAuthModes: [AUTH_MODE_JWT, AUTH_MODE_API_KEY, AUTH_MODE_SERVICE_TOKEN] }), requireSecretsAuth({ acceptedRoles: [ADMIN, MEMBER], - requiredPermissions: [PERMISSION_WRITE_SECRETS], + requiredPermissions: [PERMISSION_WRITE_SECRETS] }), secretsController.updateSecrets ); @@ -210,8 +190,7 @@ router.delete( if (Array.isArray(value)) { // case: delete multiple secrets - if (value.length === 0) - throw new Error("secrets cannot be an empty array"); + if (value.length === 0) throw new Error("secrets cannot be an empty array"); return value.every((id: string) => typeof id === "string"); } @@ -221,15 +200,11 @@ router.delete( .isEmpty(), validateRequest, requireAuth({ - acceptedAuthModes: [ - AUTH_MODE_JWT, - AUTH_MODE_API_KEY, - AUTH_MODE_SERVICE_TOKEN, - ], + acceptedAuthModes: [AUTH_MODE_JWT, AUTH_MODE_API_KEY, AUTH_MODE_SERVICE_TOKEN] }), requireSecretsAuth({ acceptedRoles: [ADMIN, MEMBER], - requiredPermissions: [PERMISSION_WRITE_SECRETS], + requiredPermissions: [PERMISSION_WRITE_SECRETS] }), secretsController.deleteSecrets ); diff --git a/backend/src/services/WebhookService.ts b/backend/src/services/WebhookService.ts new file mode 100644 index 0000000000..c66f44e47c --- /dev/null +++ b/backend/src/services/WebhookService.ts @@ -0,0 +1,93 @@ +import axios from "axios"; +import crypto from "crypto"; +import { Types } from "mongoose"; +import picomatch from "picomatch"; +import { client, getRootEncryptionKey } from "../config"; +import Webhook, { IWebhook } from "../models/webhooks"; + +export const triggerWebhookRequest = async ( + { url, encryptedSecretKey, iv, tag }: IWebhook, + payload: Record +) => { + const headers: Record = {}; + payload["timestamp"] = Date.now(); + + if (encryptedSecretKey) { + const rootEncryptionKey = await getRootEncryptionKey(); + const secretKey = client.decryptSymmetric(encryptedSecretKey, rootEncryptionKey, iv, tag); + const webhookSign = crypto + .createHmac("sha256", secretKey) + .update(JSON.stringify(payload)) + .digest("hex"); + headers["x-infisical-signature"] = `t=${payload["timestamp"]};${webhookSign}`; + } + + const req = await axios.post(url, payload, { headers }); + return req; +}; + +export const getWebhookPayload = ( + eventName: string, + workspaceId: string, + environment: string, + secretPath?: string +) => ({ + event: eventName, + project: { + workspaceId, + environment, + secretPath + } +}); + +export const triggerWebhook = async ( + workspaceId: string, + environment: string, + secretPath: string +) => { + const webhooks = await Webhook.find({ workspace: workspaceId, environment, isDisabled: false }); + // TODO(akhilmhdh): implement retry policy later, for that a cron job based approach is needed + // for exponential backoff + const toBeTriggeredHooks = webhooks.filter(({ secretPath: hookSecretPath }) => + picomatch.isMatch(secretPath, hookSecretPath, { strictSlashes: false }) + ); + const webhooksTriggered = await Promise.allSettled( + toBeTriggeredHooks.map((hook) => + triggerWebhookRequest( + hook, + getWebhookPayload("secrets.modified", workspaceId, environment, secretPath) + ) + ) + ); + const successWebhooks: Types.ObjectId[] = []; + const failedWebhooks: Array<{ id: Types.ObjectId; error: string }> = []; + webhooksTriggered.forEach((data, index) => { + if (data.status === "rejected") { + failedWebhooks.push({ id: toBeTriggeredHooks[index]._id, error: data.reason.message }); + return; + } + successWebhooks.push(toBeTriggeredHooks[index]._id); + }); + // dont remove the workspaceid and environment filter. its used to reduce the dataset before $in check + await Webhook.bulkWrite([ + { + updateMany: { + filter: { workspace: workspaceId, environment, _id: { $in: successWebhooks } }, + update: { lastStatus: "success", lastRunErrorMessage: null } + } + }, + ...failedWebhooks.map(({ id, error }) => ({ + updateOne: { + filter: { + workspace: workspaceId, + environment, + _id: id + }, + update: { + lastStatus: "failed", + lastRunErrorMessage: error + } + } + })) + ]); +}; diff --git a/backend/src/variables/event.ts b/backend/src/variables/event.ts index c5de005d19..126ede0401 100644 --- a/backend/src/variables/event.ts +++ b/backend/src/variables/event.ts @@ -1,2 +1,3 @@ export const EVENT_PUSH_SECRETS = "pushSecrets"; -export const EVENT_PULL_SECRETS = "pullSecrets"; \ No newline at end of file +export const EVENT_PULL_SECRETS = "pullSecrets"; +export const EVENT_START_INTEGRATION = "startIntegration"; From daf2e2036eb3093472d6a118309aa2b736acb241 Mon Sep 17 00:00:00 2001 From: akhilmhdh Date: Tue, 11 Jul 2023 22:53:35 +0530 Subject: [PATCH 2/5] feat(webhook): implemented ui for webhooks --- frontend/package-lock.json | 11 + frontend/package.json | 1 + frontend/public/locales/en/translations.json | 4 + frontend/src/components/v2/Button/Button.tsx | 23 +- .../src/components/v2/Tooltip/Tooltip.tsx | 4 +- frontend/src/hooks/api/index.tsx | 1 + frontend/src/hooks/api/types.ts | 1 + frontend/src/hooks/api/webhooks/index.tsx | 2 + frontend/src/hooks/api/webhooks/mutation.tsx | 67 +++ frontend/src/hooks/api/webhooks/query.tsx | 26 + frontend/src/hooks/api/webhooks/types.ts | 36 ++ frontend/src/layouts/AppLayout/AppLayout.tsx | 448 ++++++++++-------- .../ProjectSettingsPage.tsx | 57 ++- .../ProjectTabGroup/ProjectTabGroup.tsx | 39 -- .../components/ProjectTabGroup/index.tsx | 1 - .../components/WebhooksTab/AddWebhookForm.tsx | 133 ++++++ .../components/WebhooksTab/WebhooksTab.tsx | 279 +++++++++++ .../components/WebhooksTab/index.tsx | 1 + .../ProjectSettingsPage/components/index.tsx | 1 - 19 files changed, 890 insertions(+), 245 deletions(-) create mode 100644 frontend/src/hooks/api/webhooks/index.tsx create mode 100644 frontend/src/hooks/api/webhooks/mutation.tsx create mode 100644 frontend/src/hooks/api/webhooks/query.tsx create mode 100644 frontend/src/hooks/api/webhooks/types.ts delete mode 100644 frontend/src/views/Settings/ProjectSettingsPage/components/ProjectTabGroup/ProjectTabGroup.tsx delete mode 100644 frontend/src/views/Settings/ProjectSettingsPage/components/ProjectTabGroup/index.tsx create mode 100644 frontend/src/views/Settings/ProjectSettingsPage/components/WebhooksTab/AddWebhookForm.tsx create mode 100644 frontend/src/views/Settings/ProjectSettingsPage/components/WebhooksTab/WebhooksTab.tsx create mode 100644 frontend/src/views/Settings/ProjectSettingsPage/components/WebhooksTab/index.tsx diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 717741bfbf..cb3360485b 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -44,6 +44,7 @@ "classnames": "^2.3.1", "cookies": "^0.8.0", "cva": "npm:class-variance-authority@^0.4.0", + "dayjs": "^1.11.9", "framer-motion": "^6.2.3", "fs": "^0.0.2", "gray-matter": "^4.0.3", @@ -10571,6 +10572,11 @@ "integrity": "sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==", "dev": true }, + "node_modules/dayjs": { + "version": "1.11.9", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.9.tgz", + "integrity": "sha512-QvzAURSbQ0pKdIye2txOzNaHmxtUBXerpY0FJsFXUMKbIZeFm5ht1LS/jFsrncjnmtv8HsG0W2g6c0zUjZWmpA==" + }, "node_modules/debug": { "version": "4.3.4", "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", @@ -30438,6 +30444,11 @@ "integrity": "sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==", "dev": true }, + "dayjs": { + "version": "1.11.9", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.9.tgz", + "integrity": "sha512-QvzAURSbQ0pKdIye2txOzNaHmxtUBXerpY0FJsFXUMKbIZeFm5ht1LS/jFsrncjnmtv8HsG0W2g6c0zUjZWmpA==" + }, "debug": { "version": "4.3.4", "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", diff --git a/frontend/package.json b/frontend/package.json index a678504f72..f20477e6ff 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -52,6 +52,7 @@ "classnames": "^2.3.1", "cookies": "^0.8.0", "cva": "npm:class-variance-authority@^0.4.0", + "dayjs": "^1.11.9", "framer-motion": "^6.2.3", "fs": "^0.0.2", "gray-matter": "^4.0.3", diff --git a/frontend/public/locales/en/translations.json b/frontend/public/locales/en/translations.json index c387d938be..e4c671ca4f 100644 --- a/frontend/public/locales/en/translations.json +++ b/frontend/public/locales/en/translations.json @@ -251,6 +251,10 @@ } }, "settings": { + "webhooks": { + "title": "Webhooks", + "description": "Manage webhooks to setup deployment hooks for your various integrations." + }, "members": { "title": "Project Members", "description": "This page shows the members of the selected project, and allows you to modify their permissions." diff --git a/frontend/src/components/v2/Button/Button.tsx b/frontend/src/components/v2/Button/Button.tsx index 785459c4f0..b69545766c 100644 --- a/frontend/src/components/v2/Button/Button.tsx +++ b/frontend/src/components/v2/Button/Button.tsx @@ -61,7 +61,8 @@ const buttonVariants = cva( { colorSchema: "primary", variant: "star", - className: "bg-mineshaft-700 border border-mineshaft-600 hover:bg-primary hover:text-black hover:border-primary-400 duration-100" + className: + "bg-mineshaft-700 border border-mineshaft-600 hover:bg-primary hover:text-black hover:border-primary-400 duration-100" }, { colorSchema: "primary", @@ -76,12 +77,14 @@ const buttonVariants = cva( { colorSchema: "primary", variant: "outline_bg", - className: "bg-mineshaft-600 border border-mineshaft-500 hover:bg-primary/[0.1] hover:border-primary/40 text-bunker-200" + className: + "bg-mineshaft-600 border border-mineshaft-500 hover:bg-primary/[0.1] hover:border-primary/40 text-bunker-200" }, { colorSchema: "secondary", variant: "star", - className: "bg-mineshaft-700 border border-mineshaft-600 hover:bg-mineshaft hover:text-white" + className: + "bg-mineshaft-700 border border-mineshaft-600 hover:bg-mineshaft hover:text-white" }, { colorSchema: "danger", @@ -163,13 +166,13 @@ export const Button = forwardRef( type="button" className={twMerge( buttonVariants({ - className, colorSchema, size, variant, isRounded, isDisabled, - isFullWidth + isFullWidth, + className }) )} disabled={isDisabled} @@ -193,7 +196,15 @@ export const Button = forwardRef( > {leftIcon} - {children} + + {children} +
& { children: ReactNode; content?: ReactNode; isOpen?: boolean; @@ -10,7 +10,7 @@ export type TooltipProps = { asChild?: boolean; onOpenChange?: (isOpen: boolean) => void; defaultOpen?: boolean; -} & Omit; +}; export const Tooltip = ({ children, diff --git a/frontend/src/hooks/api/index.tsx b/frontend/src/hooks/api/index.tsx index 075702bc75..2996294164 100644 --- a/frontend/src/hooks/api/index.tsx +++ b/frontend/src/hooks/api/index.tsx @@ -13,4 +13,5 @@ export * from "./serviceTokens"; export * from "./subscriptions"; export * from "./tags"; export * from "./users"; +export * from "./webhooks"; export * from "./workspace"; diff --git a/frontend/src/hooks/api/types.ts b/frontend/src/hooks/api/types.ts index 1c43164106..098078090d 100644 --- a/frontend/src/hooks/api/types.ts +++ b/frontend/src/hooks/api/types.ts @@ -8,6 +8,7 @@ export type { CreateServiceTokenDTO, ServiceToken } from "./serviceTokens/types" export type { SubscriptionPlan } from "./subscriptions/types"; export type { WsTag } from "./tags/types"; export type { AddUserToWsDTO, AddUserToWsRes, OrgUser, User } from "./users/types"; +export type { TWebhook } from "./webhooks/types"; export type { CreateEnvironmentDTO, CreateWorkspaceDTO, diff --git a/frontend/src/hooks/api/webhooks/index.tsx b/frontend/src/hooks/api/webhooks/index.tsx new file mode 100644 index 0000000000..44f1b5cfc0 --- /dev/null +++ b/frontend/src/hooks/api/webhooks/index.tsx @@ -0,0 +1,2 @@ +export { useCreateWebhook, useDeleteWebhook, useTestWebhook, useUpdateWebhook } from "./mutation"; +export { useGetWebhooks } from "./query"; diff --git a/frontend/src/hooks/api/webhooks/mutation.tsx b/frontend/src/hooks/api/webhooks/mutation.tsx new file mode 100644 index 0000000000..786fbb459b --- /dev/null +++ b/frontend/src/hooks/api/webhooks/mutation.tsx @@ -0,0 +1,67 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; + +import { apiRequest } from "@app/config/request"; + +import { queryKeys } from "./query"; +import { TCreateWebhookDto, TDeleteWebhookDto, TTestWebhookDTO, TUpdateWebhookDto } from "./types"; + +export const useCreateWebhook = () => { + const queryClient = useQueryClient(); + + return useMutation<{}, {}, TCreateWebhookDto>({ + mutationFn: async (dto) => { + const { data } = await apiRequest.post("/api/v1/webhooks", dto); + return data; + }, + onSuccess: (_, { workspaceId }) => { + queryClient.invalidateQueries(queryKeys.getWebhooks(workspaceId)); + } + }); +}; + +export const useTestWebhook = () => { + const queryClient = useQueryClient(); + + return useMutation<{}, {}, TTestWebhookDTO>({ + mutationFn: async ({ webhookId }) => { + const { data } = await apiRequest.post(`/api/v1/webhooks/${webhookId}/test`); + return data; + }, + onSuccess: (_, { workspaceId }) => { + queryClient.invalidateQueries(queryKeys.getWebhooks(workspaceId)); + }, + onError: (_, { workspaceId }) => { + queryClient.invalidateQueries(queryKeys.getWebhooks(workspaceId)); + } + }); +}; + +export const useUpdateWebhook = () => { + const queryClient = useQueryClient(); + + return useMutation<{}, {}, TUpdateWebhookDto>({ + mutationFn: async (dto) => { + const { data } = await apiRequest.patch(`/api/v1/webhooks/${dto.webhookId}`, { + isDisabled: dto.isDisabled + }); + return data; + }, + onSuccess: (_, { workspaceId }) => { + queryClient.invalidateQueries(queryKeys.getWebhooks(workspaceId)); + } + }); +}; + +export const useDeleteWebhook = () => { + const queryClient = useQueryClient(); + + return useMutation<{}, {}, TDeleteWebhookDto>({ + mutationFn: async (dto) => { + const { data } = await apiRequest.delete(`/api/v1/webhooks/${dto.webhookId}`); + return data; + }, + onSuccess: (_, { workspaceId }) => { + queryClient.invalidateQueries(queryKeys.getWebhooks(workspaceId)); + } + }); +}; diff --git a/frontend/src/hooks/api/webhooks/query.tsx b/frontend/src/hooks/api/webhooks/query.tsx new file mode 100644 index 0000000000..fc18404097 --- /dev/null +++ b/frontend/src/hooks/api/webhooks/query.tsx @@ -0,0 +1,26 @@ +import { useQuery } from "@tanstack/react-query"; + +import { apiRequest } from "@app/config/request"; + +import { TWebhook } from "./types"; + +export const queryKeys = { + getWebhooks: (workspaceId: string) => ["webhooks", { workspaceId }] +}; + +const fetchWebhooks = async (workspaceId: string) => { + const { data } = await apiRequest.get<{ webhooks: TWebhook[] }>("/api/v1/webhooks", { + params: { + workspaceId + } + }); + + return data.webhooks; +}; + +export const useGetWebhooks = (workspaceId: string) => + useQuery({ + queryKey: queryKeys.getWebhooks(workspaceId), + queryFn: () => fetchWebhooks(workspaceId), + enabled: Boolean(workspaceId) + }); diff --git a/frontend/src/hooks/api/webhooks/types.ts b/frontend/src/hooks/api/webhooks/types.ts new file mode 100644 index 0000000000..8fd64bcc21 --- /dev/null +++ b/frontend/src/hooks/api/webhooks/types.ts @@ -0,0 +1,36 @@ +export type TWebhook = { + _id: string; + workspace: string; + environment: string; + secretPath: string; + url: string; + lastStatus: "success" | "failed"; + lastRunErrorMessage?: string; + isDisabled: boolean; + createdAt: string; + updatedAt: string; +}; + +export type TCreateWebhookDto = { + workspaceId: string; + environment: string; + webhookUrl: string; + webhookSecretKey?: string; + secretPath: string; +}; + +export type TUpdateWebhookDto = { + webhookId: string; + workspaceId: string; + isDisabled?: boolean; +}; + +export type TDeleteWebhookDto = { + webhookId: string; + workspaceId: string; +}; + +export type TTestWebhookDTO = { + webhookId: string; + workspaceId: string; +}; diff --git a/frontend/src/layouts/AppLayout/AppLayout.tsx b/frontend/src/layouts/AppLayout/AppLayout.tsx index 1e0c7f2240..b8d4ad87e7 100644 --- a/frontend/src/layouts/AppLayout/AppLayout.tsx +++ b/frontend/src/layouts/AppLayout/AppLayout.tsx @@ -13,7 +13,18 @@ import { useTranslation } from "react-i18next"; import Link from "next/link"; import { useRouter } from "next/router"; import { faGithub, faSlack } from "@fortawesome/free-brands-svg-icons"; -import { faAngleDown, faArrowLeft, faArrowUpRightFromSquare, faBook, faCheck, faEnvelope, faInfinity, faMobile, faPlus, faQuestion } from "@fortawesome/free-solid-svg-icons"; +import { + faAngleDown, + faArrowLeft, + faArrowUpRightFromSquare, + faBook, + faCheck, + faEnvelope, + faInfinity, + faMobile, + faPlus, + faQuestion +} from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { yupResolver } from "@hookform/resolvers/yup"; import { DropdownMenuTrigger } from "@radix-ui/react-dropdown-menu"; @@ -41,7 +52,14 @@ import { } from "@app/components/v2"; import { useOrganization, useSubscription, useUser, useWorkspace } from "@app/context"; import { usePopUp } from "@app/hooks"; -import { fetchOrgUsers, useAddUserToWs, useCreateWorkspace, useGetOrgTrialUrl, useLogoutUser, useUploadWsKey } from "@app/hooks/api"; +import { + fetchOrgUsers, + useAddUserToWs, + useCreateWorkspace, + useGetOrgTrialUrl, + useLogoutUser, + useUploadWsKey +} from "@app/hooks/api"; interface LayoutProps { children: React.ReactNode; @@ -89,7 +107,9 @@ export const AppLayout = ({ children }: LayoutProps) => { const { subscription } = useSubscription(); // const [ isLearningNoteOpen, setIsLearningNoteOpen ] = useState(true); - const isAddingProjectsAllowed = subscription?.workspaceLimit ? (subscription.workspacesUsed < subscription.workspaceLimit) : true; + const isAddingProjectsAllowed = subscription?.workspaceLimit + ? subscription.workspacesUsed < subscription.workspaceLimit + : true; const createWs = useCreateWorkspace(); const uploadWsKey = useUploadWsKey(); @@ -110,22 +130,22 @@ export const AppLayout = ({ children }: LayoutProps) => { const { t } = useTranslation(); - useEffect(() => { - const handleRouteChange = () => { - (window).Intercom("update"); - }; - - router.events.on("routeChangeComplete", handleRouteChange); - - return () => { - router.events.off("routeChangeComplete", handleRouteChange); - }; - }, []); + useEffect(() => { + const handleRouteChange = () => { + window.Intercom("update"); + }; + + router.events.on("routeChangeComplete", handleRouteChange); + + return () => { + router.events.off("routeChangeComplete", handleRouteChange); + }; + }, []); const logout = useLogoutUser(); const logOutUser = async () => { try { - console.log("Logging out...") + console.log("Logging out..."); await logout.mutateAsync(); localStorage.removeItem("protectedKey"); localStorage.removeItem("protectedKeyIV"); @@ -145,27 +165,30 @@ export const AppLayout = ({ children }: LayoutProps) => { const changeOrg = async (orgId) => { localStorage.setItem("orgData.id", orgId); - router.push(`/org/${orgId}/overview`) - } + router.push(`/org/${orgId}/overview`); + }; // TODO(akhilmhdh): This entire logic will be rechecked and will try to avoid // Placing the localstorage as much as possible // Wait till tony integrates the azure and its launched useEffect(() => { - // Put a user in an org if they're not in one yet const putUserInOrg = async () => { if (tempLocalStorage("orgData.id") === "") { localStorage.setItem("orgData.id", orgs[0]?._id); } - if (currentOrg && ( - (workspaces?.length === 0 && router.asPath.includes("project")) - || router.asPath.includes("/project/undefined") - || (!orgs?.map(org => org._id)?.includes(router.query.id) && !router.asPath.includes("project") && !router.asPath.includes("personal") && !router.asPath.includes("integration")) - )) { + if ( + currentOrg && + ((workspaces?.length === 0 && router.asPath.includes("project")) || + router.asPath.includes("/project/undefined") || + (!orgs?.map((org) => org._id)?.includes(router.query.id) && + !router.asPath.includes("project") && + !router.asPath.includes("personal") && + !router.asPath.includes("integration"))) + ) { router.push(`/org/${currentOrg?._id}/overview`); - } + } // else if (!router.asPath.includes("org") && !router.asPath.includes("project") && !router.asPath.includes("integrations") && !router.asPath.includes("personal-settings")) { // const pathSegments = router.asPath.split("/").filter((segment) => segment.length > 0); @@ -244,134 +267,171 @@ export const AppLayout = ({ children }: LayoutProps) => { <>
-