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..0521d6b856 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,31 @@ 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 + }); + } + 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"; diff --git a/docs/documentation/platform/webhooks.mdx b/docs/documentation/platform/webhooks.mdx new file mode 100644 index 0000000000..e0de7be059 --- /dev/null +++ b/docs/documentation/platform/webhooks.mdx @@ -0,0 +1,36 @@ +--- +title: "Webhooks" +description: "How Infisical webhooks works?" +--- + +Webhooks can be used to trigger changes to your integrations when secrets are modified, providing smooth integration with other third-party applications. + +![webhooks](../../images/webhooks.png) + +To create a webhook for a particular project, go to `Project Settings > Webhooks`. + +When creating a webhook, you can specify an environment and folder path (using glob patterns) to trigger only specific integrations. + +## Secret Key Verification + +A secret key is a way for users to verify that a webhook request was sent by Infisical and is intended for the correct integration. + +When you provide a secret key, Infisical will sign the payload of the webhook request using the key and attach a header called `x-infisical-signature` to the request with a payload. + +The header will be in the format `t=;`. You can then generate the signature yourself by generating a SHA256 hash of the payload with the secret key that you know. + +If the signature in the header matches the signature that you generated, then you can be sure that the request was sent by Infisical and is intended for your integration. The timestamp in the header ensures that the request is not replayed. + +### Webhook Payload Format + +```json +{ + "event": "secret.modified", + "project": { + "workspaceId":"the workspace id", + "environment": "project environment", + "secretPath": "project folder path" + }, + "timestamp": "" +} +``` diff --git a/docs/images/webhooks.png b/docs/images/webhooks.png new file mode 100644 index 0000000000..a726e77a05 Binary files /dev/null and b/docs/images/webhooks.png differ diff --git a/docs/mint.json b/docs/mint.json index d9b72e4e2a..e496d76381 100644 --- a/docs/mint.json +++ b/docs/mint.json @@ -114,6 +114,7 @@ "documentation/platform/project", "documentation/platform/folder", "documentation/platform/secret-reference", + "documentation/platform/webhooks", "documentation/platform/pit-recovery", "documentation/platform/secret-versioning", "documentation/platform/audit-logs", 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/views/DashboardPage/DashboardPage.tsx b/frontend/src/views/DashboardPage/DashboardPage.tsx index 1a3b0f73e6..1cb686414a 100644 --- a/frontend/src/views/DashboardPage/DashboardPage.tsx +++ b/frontend/src/views/DashboardPage/DashboardPage.tsx @@ -36,7 +36,7 @@ import { UpgradePlanModal } from "@app/components/v2"; import { leaveConfirmDefaultMessage } from "@app/const"; -import { useOrganization, useSubscription,useWorkspace } from "@app/context"; +import { useOrganization, useSubscription, useWorkspace } from "@app/context"; import { useLeaveConfirm, usePopUp, useToggle } from "@app/hooks"; import { useBatchSecretsOp, @@ -340,9 +340,9 @@ export const DashboardPage = ({ envFromTop }: { envFromTop: string }) => { } }; - const onAppendSecret = () => { + const onAppendSecret = () => { setSearchFilter(""); - append(DEFAULT_SECRET_VALUE) + append(DEFAULT_SECRET_VALUE); }; const onSaveSecret = async ({ secrets: userSec = [], isSnapshotMode }: FormData) => { @@ -364,6 +364,10 @@ export const DashboardPage = ({ envFromTop }: { envFromTop: string }) => { ); // type check if (!selectedEnv?.slug) return; + if (batchedSecret.length === 0) { + reset(); + return; + } try { await batchSecretOp({ requests: batchedSecret, @@ -636,7 +640,7 @@ export const DashboardPage = ({ envFromTop }: { envFromTop: string }) => { handlePopUpOpen("secretSnapshots"); return; } - + handlePopUpOpen("upgradePlan"); }} leftIcon={} @@ -905,7 +909,11 @@ export const DashboardPage = ({ envFromTop }: { envFromTop: string }) => { handlePopUpToggle("upgradePlan", isOpen)} - text={subscription.slug === null ? "You can perform point-in-time recovery under an Enterprise license" : "You can perform point-in-time recovery if you switch to Infisical's Team plan"} + text={ + subscription.slug === null + ? "You can perform point-in-time recovery under an Enterprise license" + : "You can perform point-in-time recovery if you switch to Infisical's Team plan" + } /> )}
diff --git a/frontend/src/views/Settings/ProjectSettingsPage/ProjectSettingsPage.tsx b/frontend/src/views/Settings/ProjectSettingsPage/ProjectSettingsPage.tsx index cf622e69cc..75f67aa6a5 100644 --- a/frontend/src/views/Settings/ProjectSettingsPage/ProjectSettingsPage.tsx +++ b/frontend/src/views/Settings/ProjectSettingsPage/ProjectSettingsPage.tsx @@ -1,18 +1,59 @@ +import { Fragment } from "react"; import { useTranslation } from "react-i18next"; +import { Tab } from "@headlessui/react"; -import { ProjectTabGroup } from "./components"; +import NavHeader from "@app/components/navigation/NavHeader"; + +import { ProjectGeneralTab } from "./components/ProjectGeneralTab"; +import { ProjectServiceTokensTab } from "./components/ProjectServiceTokensTab"; +import { WebhooksTab } from "./components/WebhooksTab"; + +const tabs = [ + { name: "General", key: "tab-project-general" }, + { name: "Service Tokens", key: "tab-project-service-tokens" }, + { name: "Webhooks", key: "tab-project-webhooks" } +]; export const ProjectSettingsPage = () => { const { t } = useTranslation(); return ( -
-
-
-

- {t("settings.project.title")} -

+
+
+
+
- +
+

{t("settings.project.title")}

+
+ + + {tabs.map((tab) => ( + + {({ selected }) => ( + + )} + + ))} + + + + + + + + + + + + +
); diff --git a/frontend/src/views/Settings/ProjectSettingsPage/components/ProjectTabGroup/ProjectTabGroup.tsx b/frontend/src/views/Settings/ProjectSettingsPage/components/ProjectTabGroup/ProjectTabGroup.tsx deleted file mode 100644 index c18dcb4b7d..0000000000 --- a/frontend/src/views/Settings/ProjectSettingsPage/components/ProjectTabGroup/ProjectTabGroup.tsx +++ /dev/null @@ -1,39 +0,0 @@ -import { Fragment } from "react" -import { Tab } from "@headlessui/react" - -import { ProjectGeneralTab } from "../ProjectGeneralTab"; -import { ProjectServiceTokensTab } from "../ProjectServiceTokensTab"; - -const tabs = [ - { name: "General", key: "tab-project-general" }, - { name: "Service Tokens", key: "tab-project-service-tokens" } -]; - -export const ProjectTabGroup = () => { - return ( - - - {tabs.map((tab) => ( - - {({ selected }) => ( - - )} - - ))} - - - - - - - - - - - ); -} \ No newline at end of file diff --git a/frontend/src/views/Settings/ProjectSettingsPage/components/ProjectTabGroup/index.tsx b/frontend/src/views/Settings/ProjectSettingsPage/components/ProjectTabGroup/index.tsx deleted file mode 100644 index ac1f5c50d2..0000000000 --- a/frontend/src/views/Settings/ProjectSettingsPage/components/ProjectTabGroup/index.tsx +++ /dev/null @@ -1 +0,0 @@ -export { ProjectTabGroup } from "./ProjectTabGroup"; \ No newline at end of file diff --git a/frontend/src/views/Settings/ProjectSettingsPage/components/WebhooksTab/AddWebhookForm.tsx b/frontend/src/views/Settings/ProjectSettingsPage/components/WebhooksTab/AddWebhookForm.tsx new file mode 100644 index 0000000000..a5f494318f --- /dev/null +++ b/frontend/src/views/Settings/ProjectSettingsPage/components/WebhooksTab/AddWebhookForm.tsx @@ -0,0 +1,133 @@ +import { useEffect } from "react"; +import { Controller, useForm } from "react-hook-form"; +import { yupResolver } from "@hookform/resolvers/yup"; +import * as yup from "yup"; + +import { + Button, + FormControl, + Input, + Modal, + ModalClose, + ModalContent, + Select, + SelectItem +} from "@app/components/v2"; + +const formSchema = yup.object({ + environment: yup.string().required().trim().label("Environment"), + webhookUrl: yup.string().url().required().trim().label("Webhook URL"), + webhookSecretKey: yup.string().trim().label("Secret Key"), + secretPath: yup.string().required().trim().label("Secret Path") +}); + +export type TFormSchema = yup.InferType; + +type Props = { + isOpen: boolean; + onOpenChange: (isOpen: boolean) => void; + onCreateWebhook: (data: TFormSchema) => void; + environments?: Array<{ slug: string; name: string }>; +}; + +export const AddWebhookForm = ({ + isOpen, + onOpenChange, + onCreateWebhook, + environments = [] +}: Props) => { + const { + control, + handleSubmit, + register, + reset, + formState: { errors, isSubmitting } + } = useForm({ + resolver: yupResolver(formSchema) + }); + + useEffect(() => { + if (!isOpen) { + reset(); + } + }, [isOpen]); + + return ( + + +
+
+ ( + + + + )} + /> + + + + + + + + + +
+
+ + + + +
+
+
+
+ ); +}; diff --git a/frontend/src/views/Settings/ProjectSettingsPage/components/WebhooksTab/WebhooksTab.tsx b/frontend/src/views/Settings/ProjectSettingsPage/components/WebhooksTab/WebhooksTab.tsx new file mode 100644 index 0000000000..76b928ace3 --- /dev/null +++ b/frontend/src/views/Settings/ProjectSettingsPage/components/WebhooksTab/WebhooksTab.tsx @@ -0,0 +1,279 @@ +import { useTranslation } from "react-i18next"; +import { faInfoCircle, faPlug, faPlus } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import dayjs from "dayjs"; + +import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider"; +import { + Button, + DeleteActionModal, + EmptyState, + Table, + TableContainer, + TableSkeleton, + TBody, + Td, + THead, + Tooltip, + Tr +} from "@app/components/v2"; +import { useWorkspace } from "@app/context"; +import { usePopUp } from "@app/hooks"; +import { + useCreateWebhook, + useDeleteWebhook, + useGetWebhooks, + useTestWebhook, + useUpdateWebhook +} from "@app/hooks/api"; + +import { AddWebhookForm, TFormSchema } from "./AddWebhookForm"; + +export const WebhooksTab = () => { + const { t } = useTranslation(); + const { createNotification } = useNotificationContext(); + const { currentWorkspace } = useWorkspace(); + const workspaceId = currentWorkspace?._id || ""; + const { popUp, handlePopUpOpen, handlePopUpToggle, handlePopUpClose } = usePopUp([ + "addWebhook", + "deleteWebhook" + ] as const); + + const { data: webhooks, isLoading: isWebhooksLoading } = useGetWebhooks(workspaceId); + + // mutation + const { mutateAsync: createWebhook } = useCreateWebhook(); + const { + mutateAsync: testWebhook, + variables: testWebhookVars, + isLoading: isTestWebhookSubmitting + } = useTestWebhook(); + const { + mutateAsync: updateWebhook, + variables: updateWebhookVars, + isLoading: isUpdateWebhookSubmitting + } = useUpdateWebhook(); + const { mutateAsync: deleteWebhook } = useDeleteWebhook(); + + const handleWebhookCreate = async (data: TFormSchema) => { + try { + await createWebhook({ + ...data, + workspaceId + }); + handlePopUpClose("addWebhook"); + createNotification({ + type: "success", + text: "Successfully created webhook" + }); + } catch (err) { + console.log(err); + createNotification({ + type: "error", + text: "Failed to create webhook" + }); + } + }; + + const handleWebhookDisable = async (webhookId: string, isDisabled: boolean) => { + try { + await updateWebhook({ + webhookId, + workspaceId, + isDisabled + }); + createNotification({ + type: "success", + text: "Successfully updated webhook" + }); + } catch (err) { + console.log(err); + createNotification({ + type: "error", + text: "Failed to update webhook" + }); + } + }; + + const handleWebhookDelete = async () => { + try { + const webhookId = popUp?.deleteWebhook?.data as string; + await deleteWebhook({ + webhookId, + workspaceId + }); + handlePopUpClose("deleteWebhook"); + createNotification({ + type: "success", + text: "Successfully deleted webhook" + }); + } catch (err) { + console.log(err); + createNotification({ + type: "error", + text: "Failed to delete webhook" + }); + } + }; + + const handleWebhookTest = async (webhookId: string) => { + try { + await testWebhook({ + webhookId, + workspaceId + }); + createNotification({ + type: "success", + text: "Successfully triggered webhook" + }); + } catch (err) { + console.log(err); + createNotification({ + type: "error", + text: "Failed to trigger webhook" + }); + } + }; + + return ( +
+
+

{t("settings.webhooks.title")}

+ +
+

{t("settings.webhooks.description")}

+
+ + + + + + + + + + + + + {isWebhooksLoading && } + {!isWebhooksLoading && webhooks && webhooks?.length === 0 && ( + + + + )} + {!isWebhooksLoading && + webhooks?.map( + ({ + _id: id, + url, + environment, + secretPath, + lastStatus, + isDisabled, + updatedAt, + lastRunErrorMessage + }) => ( + + + + + + + + ) + )} + +
URLEnvironmentSecret PathStatusAction
+ +
+ {url} + {environment}{secretPath} + {!lastStatus ? ( + "-" + ) : ( +
+ {lastStatus}{" "} + +
+ Updated At: {dayjs(updatedAt).format("YYYY-MM-DD, hh:mm A")} +
+ {lastRunErrorMessage && ( +
+ Error: {lastRunErrorMessage} +
+ )} +
+ } + > + + + + )} +
+
+ + + +
+
+
+
+ handlePopUpToggle("addWebhook", isOpen)} + onCreateWebhook={handleWebhookCreate} + /> + handlePopUpToggle("deleteWebhook", isOpen)} + onClose={() => handlePopUpClose("deleteWebhook")} + onDeleteApproved={handleWebhookDelete} + /> +
+ ); +}; diff --git a/frontend/src/views/Settings/ProjectSettingsPage/components/WebhooksTab/index.tsx b/frontend/src/views/Settings/ProjectSettingsPage/components/WebhooksTab/index.tsx new file mode 100644 index 0000000000..2795bd02f5 --- /dev/null +++ b/frontend/src/views/Settings/ProjectSettingsPage/components/WebhooksTab/index.tsx @@ -0,0 +1 @@ +export { WebhooksTab } from "./WebhooksTab"; diff --git a/frontend/src/views/Settings/ProjectSettingsPage/components/index.tsx b/frontend/src/views/Settings/ProjectSettingsPage/components/index.tsx index b00af72c93..5f9e1013b1 100644 --- a/frontend/src/views/Settings/ProjectSettingsPage/components/index.tsx +++ b/frontend/src/views/Settings/ProjectSettingsPage/components/index.tsx @@ -4,6 +4,5 @@ export { E2EESection } from "./E2EESection"; export { EnvironmentSection } from "./EnvironmentSection"; export { ProjectIndexSecretsSection } from "./ProjectIndexSecretsSection"; export { ProjectNameChangeSection } from "./ProjectNameChangeSection"; -export { ProjectTabGroup } from "./ProjectTabGroup"; export { SecretTagsSection } from "./SecretTagsSection"; export { ServiceTokenSection } from "./ServiceTokenSection";