Merge pull request #733 from akhilmhdh/feat/webhooks

Feat/webhooks
This commit is contained in:
Maidul Islam
2023-07-13 18:47:47 -04:00
committed by GitHub
40 changed files with 1427 additions and 426 deletions
+3 -1
View File
@@ -14,6 +14,7 @@ import * as userActionController from "./userActionController";
import * as userController from "./userController"; import * as userController from "./userController";
import * as workspaceController from "./workspaceController"; import * as workspaceController from "./workspaceController";
import * as secretScanningController from "./secretScanningController"; import * as secretScanningController from "./secretScanningController";
import * as webhookController from "./webhookController";
export { export {
authController, authController,
@@ -31,5 +32,6 @@ export {
userActionController, userActionController,
userController, userController,
workspaceController, workspaceController,
secretScanningController secretScanningController,
webhookController
}; };
@@ -2,7 +2,7 @@ import { Request, Response } from "express";
import { Types } from "mongoose"; import { Types } from "mongoose";
import { Integration } from "../../models"; import { Integration } from "../../models";
import { EventService } from "../../services"; import { EventService } from "../../services";
import { eventPushSecrets } from "../../events"; import { eventPushSecrets, eventStartIntegration } from "../../events";
import Folder from "../../models/folder"; import Folder from "../../models/folder";
import { getFolderByPath } from "../../services/FolderService"; import { getFolderByPath } from "../../services/FolderService";
import { BadRequestError } from "../../utils/errors"; import { BadRequestError } from "../../utils/errors";
@@ -27,19 +27,19 @@ export const createIntegration = async (req: Request, res: Response) => {
owner, owner,
path, path,
region, region,
secretPath, secretPath
} = req.body; } = req.body;
const folders = await Folder.findOne({ const folders = await Folder.findOne({
workspace: req.integrationAuth.workspace._id, workspace: req.integrationAuth.workspace._id,
environment: sourceEnvironment, environment: sourceEnvironment
}); });
if (folders) { if (folders) {
const folder = getFolderByPath(folders.nodes, secretPath); const folder = getFolderByPath(folders.nodes, secretPath);
if (!folder) { if (!folder) {
throw BadRequestError({ 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, region,
secretPath, secretPath,
integration: req.integrationAuth.integration, integration: req.integrationAuth.integration,
integrationAuth: new Types.ObjectId(integrationAuthId), integrationAuth: new Types.ObjectId(integrationAuthId)
}).save(); }).save();
if (integration) { if (integration) {
// trigger event - push secrets // trigger event - push secrets
EventService.handleEvent({ EventService.handleEvent({
event: eventPushSecrets({ event: eventStartIntegration({
workspaceId: integration.workspace, workspaceId: integration.workspace,
environment: sourceEnvironment, environment: sourceEnvironment
}), })
}); });
} }
return res.status(200).send({ return res.status(200).send({
integration, integration
}); });
}; };
@@ -97,26 +97,26 @@ export const updateIntegration = async (req: Request, res: Response) => {
appId, appId,
targetEnvironment, targetEnvironment,
owner, // github-specific integration param owner, // github-specific integration param
secretPath, secretPath
} = req.body; } = req.body;
const folders = await Folder.findOne({ const folders = await Folder.findOne({
workspace: req.integration.workspace, workspace: req.integration.workspace,
environment, environment
}); });
if (folders) { if (folders) {
const folder = getFolderByPath(folders.nodes, secretPath); const folder = getFolderByPath(folders.nodes, secretPath);
if (!folder) { if (!folder) {
throw BadRequestError({ throw BadRequestError({
message: "Path for service token does not exist", message: "Path for service token does not exist"
}); });
} }
} }
const integration = await Integration.findOneAndUpdate( const integration = await Integration.findOneAndUpdate(
{ {
_id: req.integration._id, _id: req.integration._id
}, },
{ {
environment, environment,
@@ -125,25 +125,25 @@ export const updateIntegration = async (req: Request, res: Response) => {
appId, appId,
targetEnvironment, targetEnvironment,
owner, owner,
secretPath, secretPath
}, },
{ {
new: true, new: true
} }
); );
if (integration) { if (integration) {
// trigger event - push secrets // trigger event - push secrets
EventService.handleEvent({ EventService.handleEvent({
event: eventPushSecrets({ event: eventStartIntegration({
workspaceId: integration.workspace, workspaceId: integration.workspace,
environment, environment
}), })
}); });
} }
return res.status(200).send({ return res.status(200).send({
integration, integration
}); });
}; };
@@ -158,12 +158,12 @@ export const deleteIntegration = async (req: Request, res: Response) => {
const { integrationId } = req.params; const { integrationId } = req.params;
const integration = await Integration.findOneAndDelete({ const integration = await Integration.findOneAndDelete({
_id: integrationId, _id: integrationId
}); });
if (!integration) throw new Error("Failed to find integration"); if (!integration) throw new Error("Failed to find integration");
return res.status(200).send({ return res.status(200).send({
integration, integration
}); });
}; };
@@ -80,7 +80,8 @@ export const pushSecrets = async (req: Request, res: Response) => {
EventService.handleEvent({ EventService.handleEvent({
event: eventPushSecrets({ event: eventPushSecrets({
workspaceId: new Types.ObjectId(workspaceId), workspaceId: new Types.ObjectId(workspaceId),
environment environment,
secretPath: "/"
}) })
}); });
@@ -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<string, string> = {};
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
});
};
+38 -22
View File
@@ -30,9 +30,11 @@ import Folder from "../../models/folder";
import { import {
getFolderByPath, getFolderByPath,
getFolderIdFromServiceToken, getFolderIdFromServiceToken,
searchByFolderId searchByFolderId,
searchByFolderIdWithDir
} from "../../services/FolderService"; } from "../../services/FolderService";
import { isValidScope } from "../../helpers/secrets"; import { isValidScope } from "../../helpers/secrets";
import path from "path";
/** /**
* Peform a batch of any specified CUD secret operations * Peform a batch of any specified CUD secret operations
@@ -47,14 +49,13 @@ export const batchSecrets = async (req: Request, res: Response) => {
const { const {
workspaceId, workspaceId,
environment, environment,
requests, requests
secretPath
}: { }: {
workspaceId: string; workspaceId: string;
environment: string; environment: string;
requests: BatchSecretRequest[]; requests: BatchSecretRequest[];
secretPath: string;
} = req.body; } = req.body;
let secretPath = req.body.secretPath as string;
let folderId = req.body.folderId as string; let folderId = req.body.folderId as string;
const createSecrets: BatchSecret[] = []; const createSecrets: BatchSecret[] = [];
@@ -68,10 +69,6 @@ export const batchSecrets = async (req: Request, res: Response) => {
}); });
const folders = await Folder.findOne({ workspace: workspaceId, environment }); 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) { if (req.authData.authPayload instanceof ServiceTokenData) {
const isValidScopeAccess = isValidScope(req.authData.authPayload, environment, secretPath); 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); 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) { for await (const request of requests) {
// do a validation // do a validation
@@ -319,7 +325,10 @@ export const batchSecrets = async (req: Request, res: Response) => {
// // trigger event - push secrets // // trigger event - push secrets
await EventService.handleEvent({ await EventService.handleEvent({
event: eventPushSecrets({ 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 // trigger event - push secrets
await EventService.handleEvent({ await EventService.handleEvent({
event: eventPushSecrets({ event: eventPushSecrets({
workspaceId: new Types.ObjectId(workspaceId) workspaceId: new Types.ObjectId(workspaceId),
environment,
secretPath: secretPath || "/"
}) })
}); });
}, 5000); }, 5000);
@@ -1033,13 +1044,16 @@ export const updateSecrets = async (req: Request, res: Response) => {
Object.keys(workspaceSecretObj).forEach(async (key) => { Object.keys(workspaceSecretObj).forEach(async (key) => {
// trigger event - push secrets // trigger event - push secrets
setTimeout(async () => { // This route is not used anymore thus keep it commented out as it does not expose environment
await EventService.handleEvent({ // it will end up creating a lot of requests from the server
event: eventPushSecrets({ // setTimeout(async () => {
workspaceId: new Types.ObjectId(key) // await EventService.handleEvent({
}) // event: eventPushSecrets({
}); // workspaceId: new Types.ObjectId(key),
}, 10000); // environment,
// })
// });
// }, 10000);
const updateAction = await EELogService.createAction({ const updateAction = await EELogService.createAction({
name: ACTION_UPDATE_SECRETS, name: ACTION_UPDATE_SECRETS,
@@ -1174,11 +1188,13 @@ export const deleteSecrets = async (req: Request, res: Response) => {
Object.keys(workspaceSecretObj).forEach(async (key) => { Object.keys(workspaceSecretObj).forEach(async (key) => {
// trigger event - push secrets // trigger event - push secrets
await EventService.handleEvent({ // DEPRECIATED(akhilmhdh): as this would cause server to send so many request
event: eventPushSecrets({ // and this route is not used anymore thus like snapshot keeping it commented out
workspaceId: new Types.ObjectId(key) // await EventService.handleEvent({
}) // event: eventPushSecrets({
}); // workspaceId: new Types.ObjectId(key)
// })
// });
const deleteAction = await EELogService.createAction({ const deleteAction = await EELogService.createAction({
name: ACTION_DELETE_SECRETS, name: ACTION_DELETE_SECRETS,
userId: req.user?._id, userId: req.user?._id,
@@ -1,15 +1,10 @@
import { Request, Response } from "express"; import { Request, Response } from "express";
import { Types } from "mongoose"; import { Types } from "mongoose";
import { import { Key, Membership, ServiceTokenData, Workspace } from "../../models";
Key,
Membership,
ServiceTokenData,
Workspace,
} from "../../models";
import { import {
pullSecrets as pull, pullSecrets as pull,
v2PushSecrets as push, v2PushSecrets as push,
reformatPullSecrets, reformatPullSecrets
} from "../../helpers/secret"; } from "../../helpers/secret";
import { pushKeys } from "../../helpers/key"; import { pushKeys } from "../../helpers/key";
import { EventService, TelemetryService } from "../../services"; import { EventService, TelemetryService } from "../../services";
@@ -62,13 +57,13 @@ export const pushWorkspaceSecrets = async (req: Request, res: Response) => {
environment, environment,
secrets, secrets,
channel: channel ? channel : "cli", channel: channel ? channel : "cli",
ipAddress: req.realIP, ipAddress: req.realIP
}); });
await pushKeys({ await pushKeys({
userId: req.user._id, userId: req.user._id,
workspaceId, workspaceId,
keys, keys
}); });
if (postHogClient) { if (postHogClient) {
@@ -79,8 +74,8 @@ export const pushWorkspaceSecrets = async (req: Request, res: Response) => {
numberOfSecrets: secrets.length, numberOfSecrets: secrets.length,
environment, environment,
workspaceId, workspaceId,
channel: channel ? channel : "cli", channel: channel ? channel : "cli"
}, }
}); });
} }
@@ -89,11 +84,12 @@ export const pushWorkspaceSecrets = async (req: Request, res: Response) => {
event: eventPushSecrets({ event: eventPushSecrets({
workspaceId: new Types.ObjectId(workspaceId), workspaceId: new Types.ObjectId(workspaceId),
environment, environment,
}), secretPath: "/"
})
}); });
return res.status(200).send({ return res.status(200).send({
message: "Successfully uploaded workspace secrets", message: "Successfully uploaded workspace secrets"
}); });
}; };
@@ -128,7 +124,7 @@ export const pullSecrets = async (req: Request, res: Response) => {
workspaceId, workspaceId,
environment, environment,
channel: channel ? channel : "cli", channel: channel ? channel : "cli",
ipAddress: req.realIP, ipAddress: req.realIP
}); });
if (channel !== "cli") { if (channel !== "cli") {
@@ -144,13 +140,13 @@ export const pullSecrets = async (req: Request, res: Response) => {
numberOfSecrets: secrets.length, numberOfSecrets: secrets.length,
environment, environment,
workspaceId, workspaceId,
channel: channel ? channel : "cli", channel: channel ? channel : "cli"
}, }
}); });
} }
return res.status(200).send({ return res.status(200).send({
secrets, secrets
}); });
}; };
@@ -188,29 +184,24 @@ export const getWorkspaceKey = async (req: Request, res: Response) => {
key = await Key.findOne({ key = await Key.findOne({
workspace: workspaceId, workspace: workspaceId,
receiver: req.user._id, receiver: req.user._id
}).populate("sender", "+publicKey"); }).populate("sender", "+publicKey");
if (!key) throw new Error("Failed to find workspace key"); if (!key) throw new Error("Failed to find workspace key");
return res.status(200).json(key); return res.status(200).json(key);
} };
export const getWorkspaceServiceTokenData = async ( export const getWorkspaceServiceTokenData = async (req: Request, res: Response) => {
req: Request,
res: Response
) => {
const { workspaceId } = req.params; const { workspaceId } = req.params;
const serviceTokenData = await ServiceTokenData const serviceTokenData = await ServiceTokenData.find({
.find({ workspace: workspaceId
workspace: workspaceId, }).select("+encryptedKey +iv +tag");
})
.select("+encryptedKey +iv +tag");
return res.status(200).send({ return res.status(200).send({
serviceTokenData, serviceTokenData
}); });
} };
/** /**
* Return memberships for workspace with id [workspaceId] * Return memberships for workspace with id [workspaceId]
@@ -255,13 +246,13 @@ export const getWorkspaceMemberships = async (req: Request, res: Response) => {
const { workspaceId } = req.params; const { workspaceId } = req.params;
const memberships = await Membership.find({ const memberships = await Membership.find({
workspace: workspaceId, workspace: workspaceId
}).populate("user", "+publicKey"); }).populate("user", "+publicKey");
return res.status(200).send({ return res.status(200).send({
memberships, memberships
}); });
} };
/** /**
* Update role of membership with id [membershipId] to role [role] * Update role of membership with id [membershipId] to role [role]
@@ -323,24 +314,23 @@ export const updateWorkspaceMembership = async (req: Request, res: Response) =>
} }
} }
*/ */
const { const { membershipId } = req.params;
membershipId,
} = req.params;
const { role } = req.body; const { role } = req.body;
const membership = await Membership.findByIdAndUpdate( const membership = await Membership.findByIdAndUpdate(
membershipId, membershipId,
{ {
role, role
}, { },
new: true, {
new: true
} }
); );
return res.status(200).send({ return res.status(200).send({
membership, membership
}); });
} };
/** /**
* Delete workspace membership with id [membershipId] * Delete workspace membership with id [membershipId]
@@ -385,9 +375,7 @@ export const deleteWorkspaceMembership = async (req: Request, res: Response) =>
} }
} }
*/ */
const { const { membershipId } = req.params;
membershipId,
} = req.params;
const membership = await Membership.findByIdAndDelete(membershipId); const membership = await Membership.findByIdAndDelete(membershipId);
@@ -395,13 +383,13 @@ export const deleteWorkspaceMembership = async (req: Request, res: Response) =>
await Key.deleteMany({ await Key.deleteMany({
receiver: membership.user, receiver: membership.user,
workspace: membership.workspace, workspace: membership.workspace
}); });
return res.status(200).send({ return res.status(200).send({
membership, membership
}); });
} };
/** /**
* Change autoCapitilzation Rule of workspace * Change autoCapitilzation Rule of workspace
@@ -415,18 +403,18 @@ export const toggleAutoCapitalization = async (req: Request, res: Response) => {
const workspace = await Workspace.findOneAndUpdate( const workspace = await Workspace.findOneAndUpdate(
{ {
_id: workspaceId, _id: workspaceId
}, },
{ {
autoCapitalization, autoCapitalization
}, },
{ {
new: true, new: true
} }
); );
return res.status(200).send({ return res.status(200).send({
message: "Successfully changed autoCapitalization setting", message: "Successfully changed autoCapitalization setting",
workspace, workspace
}); });
}; };
+53 -71
View File
@@ -21,22 +21,22 @@ export const getSecretsRaw = async (req: Request, res: Response) => {
workspaceId: new Types.ObjectId(workspaceId), workspaceId: new Types.ObjectId(workspaceId),
environment, environment,
secretPath, secretPath,
authData: req.authData, authData: req.authData
}); });
const key = await BotService.getWorkspaceKeyWithBot({ const key = await BotService.getWorkspaceKeyWithBot({
workspaceId: new Types.ObjectId(workspaceId), workspaceId: new Types.ObjectId(workspaceId)
}); });
return res.status(200).send({ return res.status(200).send({
secrets: secrets.map((secret) => { secrets: secrets.map((secret) => {
const rep = repackageSecretToRaw({ const rep = repackageSecretToRaw({
secret, secret,
key, key
}); });
return rep; return rep;
}), })
}); });
}; };
@@ -58,18 +58,18 @@ export const getSecretByNameRaw = async (req: Request, res: Response) => {
environment, environment,
type, type,
secretPath, secretPath,
authData: req.authData, authData: req.authData
}); });
const key = await BotService.getWorkspaceKeyWithBot({ const key = await BotService.getWorkspaceKeyWithBot({
workspaceId: new Types.ObjectId(workspaceId), workspaceId: new Types.ObjectId(workspaceId)
}); });
return res.status(200).send({ return res.status(200).send({
secret: repackageSecretToRaw({ secret: repackageSecretToRaw({
secret, secret,
key, key
}), })
}); });
}; };
@@ -80,32 +80,25 @@ export const getSecretByNameRaw = async (req: Request, res: Response) => {
*/ */
export const createSecretRaw = async (req: Request, res: Response) => { export const createSecretRaw = async (req: Request, res: Response) => {
const { secretName } = req.params; const { secretName } = req.params;
const { const { workspaceId, environment, type, secretValue, secretComment, secretPath = "/" } = req.body;
workspaceId,
environment,
type,
secretValue,
secretComment,
secretPath = "/",
} = req.body;
const key = await BotService.getWorkspaceKeyWithBot({ const key = await BotService.getWorkspaceKeyWithBot({
workspaceId: new Types.ObjectId(workspaceId), workspaceId: new Types.ObjectId(workspaceId)
}); });
const secretKeyEncrypted = encryptSymmetric128BitHexKeyUTF8({ const secretKeyEncrypted = encryptSymmetric128BitHexKeyUTF8({
plaintext: secretName, plaintext: secretName,
key, key
}); });
const secretValueEncrypted = encryptSymmetric128BitHexKeyUTF8({ const secretValueEncrypted = encryptSymmetric128BitHexKeyUTF8({
plaintext: secretValue, plaintext: secretValue,
key, key
}); });
const secretCommentEncrypted = encryptSymmetric128BitHexKeyUTF8({ const secretCommentEncrypted = encryptSymmetric128BitHexKeyUTF8({
plaintext: secretComment, plaintext: secretComment,
key, key
}); });
const secret = await SecretService.createSecret({ const secret = await SecretService.createSecret({
@@ -123,14 +116,15 @@ export const createSecretRaw = async (req: Request, res: Response) => {
secretPath, secretPath,
secretCommentCiphertext: secretCommentEncrypted.ciphertext, secretCommentCiphertext: secretCommentEncrypted.ciphertext,
secretCommentIV: secretCommentEncrypted.iv, secretCommentIV: secretCommentEncrypted.iv,
secretCommentTag: secretCommentEncrypted.tag, secretCommentTag: secretCommentEncrypted.tag
}); });
await EventService.handleEvent({ await EventService.handleEvent({
event: eventPushSecrets({ event: eventPushSecrets({
workspaceId: new Types.ObjectId(workspaceId), workspaceId: new Types.ObjectId(workspaceId),
environment, environment,
}), secretPath
})
}); });
const secretWithoutBlindIndex = secret.toObject(); const secretWithoutBlindIndex = secret.toObject();
@@ -139,10 +133,10 @@ export const createSecretRaw = async (req: Request, res: Response) => {
return res.status(200).send({ return res.status(200).send({
secret: repackageSecretToRaw({ secret: repackageSecretToRaw({
secret: secretWithoutBlindIndex, secret: secretWithoutBlindIndex,
key, key
}), })
}); });
} };
/** /**
* Update secret with name [secretName] * 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) => { export const updateSecretByNameRaw = async (req: Request, res: Response) => {
const { secretName } = req.params; const { secretName } = req.params;
const { const { workspaceId, environment, type, secretValue, secretPath = "/" } = req.body;
workspaceId,
environment,
type,
secretValue,
secretPath = "/",
} = req.body;
const key = await BotService.getWorkspaceKeyWithBot({ const key = await BotService.getWorkspaceKeyWithBot({
workspaceId: new Types.ObjectId(workspaceId), workspaceId: new Types.ObjectId(workspaceId)
}); });
const secretValueEncrypted = encryptSymmetric128BitHexKeyUTF8({ const secretValueEncrypted = encryptSymmetric128BitHexKeyUTF8({
plaintext: secretValue, plaintext: secretValue,
key, key
}); });
const secret = await SecretService.updateSecret({ const secret = await SecretService.updateSecret({
@@ -177,21 +165,22 @@ export const updateSecretByNameRaw = async (req: Request, res: Response) => {
secretValueCiphertext: secretValueEncrypted.ciphertext, secretValueCiphertext: secretValueEncrypted.ciphertext,
secretValueIV: secretValueEncrypted.iv, secretValueIV: secretValueEncrypted.iv,
secretValueTag: secretValueEncrypted.tag, secretValueTag: secretValueEncrypted.tag,
secretPath, secretPath
}); });
await EventService.handleEvent({ await EventService.handleEvent({
event: eventPushSecrets({ event: eventPushSecrets({
workspaceId: new Types.ObjectId(workspaceId), workspaceId: new Types.ObjectId(workspaceId),
environment, environment,
}), secretPath
})
}); });
return res.status(200).send({ return res.status(200).send({
secret: repackageSecretToRaw({ secret: repackageSecretToRaw({
secret, secret,
key, key
}), })
}); });
}; };
@@ -202,12 +191,7 @@ export const updateSecretByNameRaw = async (req: Request, res: Response) => {
*/ */
export const deleteSecretByNameRaw = async (req: Request, res: Response) => { export const deleteSecretByNameRaw = async (req: Request, res: Response) => {
const { secretName } = req.params; const { secretName } = req.params;
const { const { workspaceId, environment, type, secretPath = "/" } = req.body;
workspaceId,
environment,
type,
secretPath = "/",
} = req.body;
const { secret } = await SecretService.deleteSecret({ const { secret } = await SecretService.deleteSecret({
secretName, secretName,
@@ -215,25 +199,26 @@ export const deleteSecretByNameRaw = async (req: Request, res: Response) => {
environment, environment,
type, type,
authData: req.authData, authData: req.authData,
secretPath, secretPath
}); });
await EventService.handleEvent({ await EventService.handleEvent({
event: eventPushSecrets({ event: eventPushSecrets({
workspaceId: new Types.ObjectId(workspaceId), workspaceId: new Types.ObjectId(workspaceId),
environment, environment,
}), secretPath
})
}); });
const key = await BotService.getWorkspaceKeyWithBot({ const key = await BotService.getWorkspaceKeyWithBot({
workspaceId: new Types.ObjectId(workspaceId), workspaceId: new Types.ObjectId(workspaceId)
}); });
return res.status(200).send({ return res.status(200).send({
secret: repackageSecretToRaw({ secret: repackageSecretToRaw({
secret, secret,
key, key
}), })
}); });
}; };
@@ -252,11 +237,11 @@ export const getSecrets = async (req: Request, res: Response) => {
workspaceId: new Types.ObjectId(workspaceId), workspaceId: new Types.ObjectId(workspaceId),
environment, environment,
secretPath, secretPath,
authData: req.authData, authData: req.authData
}); });
return res.status(200).send({ return res.status(200).send({
secrets, secrets
}); });
}; };
@@ -278,11 +263,11 @@ export const getSecretByName = async (req: Request, res: Response) => {
environment, environment,
type, type,
secretPath, secretPath,
authData: req.authData, authData: req.authData
}); });
return res.status(200).send({ return res.status(200).send({
secret, secret
}); });
}; };
@@ -306,7 +291,7 @@ export const createSecret = async (req: Request, res: Response) => {
secretCommentCiphertext, secretCommentCiphertext,
secretCommentIV, secretCommentIV,
secretCommentTag, secretCommentTag,
secretPath = "/", secretPath = "/"
} = req.body; } = req.body;
const secret = await SecretService.createSecret({ const secret = await SecretService.createSecret({
@@ -324,25 +309,25 @@ export const createSecret = async (req: Request, res: Response) => {
secretPath, secretPath,
secretCommentCiphertext, secretCommentCiphertext,
secretCommentIV, secretCommentIV,
secretCommentTag, secretCommentTag
}); });
await EventService.handleEvent({ await EventService.handleEvent({
event: eventPushSecrets({ event: eventPushSecrets({
workspaceId: new Types.ObjectId(workspaceId), workspaceId: new Types.ObjectId(workspaceId),
environment, environment,
}), secretPath
})
}); });
const secretWithoutBlindIndex = secret.toObject(); const secretWithoutBlindIndex = secret.toObject();
delete secretWithoutBlindIndex.secretBlindIndex; delete secretWithoutBlindIndex.secretBlindIndex;
return res.status(200).send({ return res.status(200).send({
secret: secretWithoutBlindIndex, secret: secretWithoutBlindIndex
}); });
}; };
/** /**
* Update secret with name [secretName] * Update secret with name [secretName]
* @param req * @param req
@@ -357,7 +342,7 @@ export const updateSecretByName = async (req: Request, res: Response) => {
secretValueCiphertext, secretValueCiphertext,
secretValueIV, secretValueIV,
secretValueTag, secretValueTag,
secretPath = "/", secretPath = "/"
} = req.body; } = req.body;
const secret = await SecretService.updateSecret({ const secret = await SecretService.updateSecret({
@@ -369,18 +354,19 @@ export const updateSecretByName = async (req: Request, res: Response) => {
secretValueCiphertext, secretValueCiphertext,
secretValueIV, secretValueIV,
secretValueTag, secretValueTag,
secretPath, secretPath
}); });
await EventService.handleEvent({ await EventService.handleEvent({
event: eventPushSecrets({ event: eventPushSecrets({
workspaceId: new Types.ObjectId(workspaceId), workspaceId: new Types.ObjectId(workspaceId),
environment, environment,
}), secretPath
})
}); });
return res.status(200).send({ 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) => { export const deleteSecretByName = async (req: Request, res: Response) => {
const { secretName } = req.params; const { secretName } = req.params;
const { const { workspaceId, environment, type, secretPath = "/" } = req.body;
workspaceId,
environment,
type,
secretPath = "/",
} = req.body;
const { secret } = await SecretService.deleteSecret({ const { secret } = await SecretService.deleteSecret({
secretName, secretName,
@@ -404,17 +385,18 @@ export const deleteSecretByName = async (req: Request, res: Response) => {
environment, environment,
type, type,
authData: req.authData, authData: req.authData,
secretPath, secretPath
}); });
await EventService.handleEvent({ await EventService.handleEvent({
event: eventPushSecrets({ event: eventPushSecrets({
workspaceId: new Types.ObjectId(workspaceId), workspaceId: new Types.ObjectId(workspaceId),
environment, environment,
}), secretPath
})
}); });
return res.status(200).send({ return res.status(200).send({
secret, secret
}); });
}; };
+3 -4
View File
@@ -1,5 +1,4 @@
import { eventPushSecrets } from "./secret" import { eventPushSecrets } from "./secret";
import { eventStartIntegration } from "./integration";
export { export { eventPushSecrets, eventStartIntegration };
eventPushSecrets,
}
+23
View File
@@ -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: {}
};
};
+15 -25
View File
@@ -1,8 +1,5 @@
import { Types } from "mongoose"; import { Types } from "mongoose";
import { import { EVENT_PULL_SECRETS, EVENT_PUSH_SECRETS } from "../variables";
EVENT_PULL_SECRETS,
EVENT_PUSH_SECRETS,
} from "../variables";
interface PushSecret { interface PushSecret {
ciphertextKey: string; ciphertextKey: string;
@@ -25,19 +22,20 @@ interface PushSecret {
const eventPushSecrets = ({ const eventPushSecrets = ({
workspaceId, workspaceId,
environment, environment,
secretPath
}: { }: {
workspaceId: Types.ObjectId; workspaceId: Types.ObjectId;
environment?: string; environment: string;
secretPath: string;
}) => { }) => {
return ({ return {
name: EVENT_PUSH_SECRETS, name: EVENT_PUSH_SECRETS,
workspaceId, workspaceId,
environment, environment,
payload: { secretPath,
payload: {}
}, };
}); };
}
/** /**
* Return event for pulling secrets * Return event for pulling secrets
@@ -45,20 +43,12 @@ const eventPushSecrets = ({
* @param {String} obj.workspaceId - id of workspace to pull secrets from * @param {String} obj.workspaceId - id of workspace to pull secrets from
* @returns * @returns
*/ */
const eventPullSecrets = ({ const eventPullSecrets = ({ workspaceId }: { workspaceId: string }) => {
workspaceId, return {
}: {
workspaceId: string;
}) => {
return ({
name: EVENT_PULL_SECRETS, name: EVENT_PULL_SECRETS,
workspaceId, workspaceId,
payload: { payload: {}
};
};
}, export { eventPushSecrets };
});
}
export {
eventPushSecrets,
}
+17 -6
View File
@@ -1,12 +1,14 @@
import { Types } from "mongoose"; import { Types } from "mongoose";
import { Bot } from "../models"; import { Bot } from "../models";
import { EVENT_PUSH_SECRETS } from "../variables"; import { EVENT_PUSH_SECRETS, EVENT_START_INTEGRATION } from "../variables";
import { IntegrationService } from "../services"; import { IntegrationService } from "../services";
import { triggerWebhook } from "../services/WebhookService";
interface Event { interface Event {
name: string; name: string;
workspaceId: Types.ObjectId; workspaceId: Types.ObjectId;
environment?: string; environment?: string;
secretPath?: string;
payload: any; payload: any;
} }
@@ -19,22 +21,31 @@ interface Event {
* @param {Object} obj.event.payload - payload of event (depends on event) * @param {Object} obj.event.payload - payload of event (depends on event)
*/ */
export const handleEventHelper = async ({ event }: { event: Event }) => { export const handleEventHelper = async ({ event }: { event: Event }) => {
const { workspaceId, environment } = event; const { workspaceId, environment, secretPath } = event;
// TODO: moduralize bot check into separate function // TODO: moduralize bot check into separate function
const bot = await Bot.findOne({ const bot = await Bot.findOne({
workspace: workspaceId, workspace: workspaceId,
isActive: true, isActive: true
}); });
if (!bot) return;
switch (event.name) { switch (event.name) {
case EVENT_PUSH_SECRETS: case EVENT_PUSH_SECRETS:
if (bot) {
await IntegrationService.syncIntegrations({
workspaceId,
environment
});
}
triggerWebhook(workspaceId.toString(), environment || "", secretPath || "");
break;
case EVENT_START_INTEGRATION:
if (bot) {
IntegrationService.syncIntegrations({ IntegrationService.syncIntegrations({
workspaceId, workspaceId,
environment, environment
}); });
}
break; break;
} }
}; };
+8 -8
View File
@@ -20,7 +20,7 @@ import {
organizations as eeOrganizationsRouter, organizations as eeOrganizationsRouter,
secret as eeSecretRouter, secret as eeSecretRouter,
secretSnapshot as eeSecretSnapshotRouter, secretSnapshot as eeSecretSnapshotRouter,
workspace as eeWorkspaceRouter, workspace as eeWorkspaceRouter
} from "./ee/routes/v1"; } from "./ee/routes/v1";
import { import {
auth as v1AuthRouter, auth as v1AuthRouter,
@@ -41,6 +41,7 @@ import {
userAction as v1UserActionRouter, userAction as v1UserActionRouter,
user as v1UserRouter, user as v1UserRouter,
workspace as v1WorkspaceRouter, workspace as v1WorkspaceRouter,
webhooks as v1WebhooksRouter
} from "./routes/v1"; } from "./routes/v1";
import { import {
auth as v2AuthRouter, auth as v2AuthRouter,
@@ -53,13 +54,13 @@ import {
serviceTokenData as v2ServiceTokenDataRouter, serviceTokenData as v2ServiceTokenDataRouter,
serviceAccounts as v2ServiceAccountsRouter, serviceAccounts as v2ServiceAccountsRouter,
environment as v2EnvironmentRouter, environment as v2EnvironmentRouter,
tags as v2TagsRouter, tags as v2TagsRouter
} from "./routes/v2"; } from "./routes/v2";
import { import {
auth as v3AuthRouter, auth as v3AuthRouter,
secrets as v3SecretsRouter, secrets as v3SecretsRouter,
signup as v3SignupRouter, signup as v3SignupRouter,
workspaces as v3WorkspacesRouter, workspaces as v3WorkspacesRouter
} from "./routes/v3"; } from "./routes/v3";
import { healthCheck } from "./routes/status"; import { healthCheck } from "./routes/status";
import { getLogger } from "./utils/logger"; import { getLogger } from "./utils/logger";
@@ -80,7 +81,7 @@ const main = async () => {
app.use( app.use(
cors({ cors({
credentials: true, 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/integration-auth", v1IntegrationAuthRouter);
app.use("/api/v1/folders", v1SecretsFolder); app.use("/api/v1/folders", v1SecretsFolder);
app.use("/api/v1/secret-scanning", v1SecretScanningRouter); app.use("/api/v1/secret-scanning", v1SecretScanningRouter);
app.use("/api/v1/webhooks", v1WebhooksRouter);
// v2 routes (improvements) // v2 routes (improvements)
app.use("/api/v2/signup", v2SignupRouter); app.use("/api/v2/signup", v2SignupRouter);
@@ -157,7 +159,7 @@ const main = async () => {
if (res.headersSent) return next(); if (res.headersSent) return next();
next( next(
RouteNotFoundError({ 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); app.use(requestErrorHandler);
const server = app.listen(await getPort(), async () => { const server = app.listen(await getPort(), async () => {
(await getLogger("backend-main")).info( (await getLogger("backend-main")).info(`Server started listening at port ${await getPort()}`);
`Server started listening at port ${await getPort()}`
);
}); });
// await createTestUserForDevelopment(); // await createTestUserForDevelopment();
+85
View File
@@ -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<IWebhook>(
{
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<IWebhook>("Webhook", WebhookSchema);
export default Webhook;
+3 -1
View File
@@ -16,6 +16,7 @@ import integration from "./integration";
import integrationAuth from "./integrationAuth"; import integrationAuth from "./integrationAuth";
import secretsFolder from "./secretsFolder"; import secretsFolder from "./secretsFolder";
import secretScanning from "./secretScanning"; import secretScanning from "./secretScanning";
import webhooks from "./webhook";
export { export {
signup, signup,
@@ -35,5 +36,6 @@ export {
integration, integration,
integrationAuth, integrationAuth,
secretsFolder, secretsFolder,
secretScanning secretScanning,
webhooks
}; };
+75
View File
@@ -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;
+20 -45
View File
@@ -5,7 +5,7 @@ import {
requireAuth, requireAuth,
requireSecretsAuth, requireSecretsAuth,
requireWorkspaceAuth, requireWorkspaceAuth,
validateRequest, validateRequest
} from "../../middleware"; } from "../../middleware";
import { validateClientForSecrets } from "../../validation"; import { validateClientForSecrets } from "../../validation";
import { body, query } from "express-validator"; import { body, query } from "express-validator";
@@ -20,22 +20,18 @@ import {
PERMISSION_READ_SECRETS, PERMISSION_READ_SECRETS,
PERMISSION_WRITE_SECRETS, PERMISSION_WRITE_SECRETS,
SECRET_PERSONAL, SECRET_PERSONAL,
SECRET_SHARED, SECRET_SHARED
} from "../../variables"; } from "../../variables";
import { BatchSecretRequest } from "../../types/secret"; import { BatchSecretRequest } from "../../types/secret";
router.post( router.post(
"/batch", "/batch",
requireAuth({ requireAuth({
acceptedAuthModes: [ acceptedAuthModes: [AUTH_MODE_JWT, AUTH_MODE_API_KEY, AUTH_MODE_SERVICE_TOKEN]
AUTH_MODE_JWT,
AUTH_MODE_API_KEY,
AUTH_MODE_SERVICE_TOKEN,
],
}), }),
requireWorkspaceAuth({ requireWorkspaceAuth({
acceptedRoles: [ADMIN, MEMBER], acceptedRoles: [ADMIN, MEMBER],
locationWorkspaceId: "body", locationWorkspaceId: "body"
}), }),
body("workspaceId").exists().isString().trim(), body("workspaceId").exists().isString().trim(),
body("folderId").default("root").isString().trim(), body("folderId").default("root").isString().trim(),
@@ -52,10 +48,8 @@ router.post(
if (secretIds.length > 0) { if (secretIds.length > 0) {
req.secrets = await validateClientForSecrets({ req.secrets = await validateClientForSecrets({
authData: req.authData, authData: req.authData,
secretIds: secretIds.map( secretIds: secretIds.map((secretId: string) => new Types.ObjectId(secretId)),
(secretId: string) => new Types.ObjectId(secretId) requiredPermissions: []
),
requiredPermissions: [],
}); });
} }
} }
@@ -76,14 +70,11 @@ router.post(
.custom((value) => { .custom((value) => {
if (Array.isArray(value)) { if (Array.isArray(value)) {
// case: create multiple secrets // case: create multiple secrets
if (value.length === 0) if (value.length === 0) throw new Error("secrets cannot be an empty array");
throw new Error("secrets cannot be an empty array");
for (const secret of value) { for (const secret of value) {
if ( if (
!secret.type || !secret.type ||
!( !(secret.type === SECRET_PERSONAL || secret.type === SECRET_SHARED) ||
secret.type === SECRET_PERSONAL || secret.type === SECRET_SHARED
) ||
!secret.secretKeyCiphertext || !secret.secretKeyCiphertext ||
!secret.secretKeyIV || !secret.secretKeyIV ||
!secret.secretKeyTag || !secret.secretKeyTag ||
@@ -108,9 +99,7 @@ router.post(
!value.secretValueIV || !value.secretValueIV ||
!value.secretValueTag !value.secretValueTag
) { ) {
throw new Error( throw new Error("secrets object is missing required secret properties");
"secrets object is missing required secret properties"
);
} }
} else { } else {
throw new Error("secrets must be an object or an array of objects"); throw new Error("secrets must be an object or an array of objects");
@@ -120,17 +109,13 @@ router.post(
}), }),
validateRequest, validateRequest,
requireAuth({ requireAuth({
acceptedAuthModes: [ acceptedAuthModes: [AUTH_MODE_JWT, AUTH_MODE_API_KEY, AUTH_MODE_SERVICE_TOKEN]
AUTH_MODE_JWT,
AUTH_MODE_API_KEY,
AUTH_MODE_SERVICE_TOKEN,
],
}), }),
requireWorkspaceAuth({ requireWorkspaceAuth({
acceptedRoles: [ADMIN, MEMBER], acceptedRoles: [ADMIN, MEMBER],
locationWorkspaceId: "body", locationWorkspaceId: "body",
locationEnvironment: "body", locationEnvironment: "body",
requiredPermissions: [PERMISSION_WRITE_SECRETS], requiredPermissions: [PERMISSION_WRITE_SECRETS]
}), }),
secretsController.createSecrets secretsController.createSecrets
); );
@@ -148,14 +133,14 @@ router.get(
AUTH_MODE_JWT, AUTH_MODE_JWT,
AUTH_MODE_API_KEY, AUTH_MODE_API_KEY,
AUTH_MODE_SERVICE_TOKEN, AUTH_MODE_SERVICE_TOKEN,
AUTH_MODE_SERVICE_ACCOUNT, AUTH_MODE_SERVICE_ACCOUNT
], ]
}), }),
requireWorkspaceAuth({ requireWorkspaceAuth({
acceptedRoles: [ADMIN, MEMBER], acceptedRoles: [ADMIN, MEMBER],
locationWorkspaceId: "query", locationWorkspaceId: "query",
locationEnvironment: "query", locationEnvironment: "query",
requiredPermissions: [PERMISSION_READ_SECRETS], requiredPermissions: [PERMISSION_READ_SECRETS]
}), }),
secretsController.getSecrets secretsController.getSecrets
); );
@@ -167,8 +152,7 @@ router.patch(
.custom((value) => { .custom((value) => {
if (Array.isArray(value)) { if (Array.isArray(value)) {
// case: update multiple secrets // case: update multiple secrets
if (value.length === 0) if (value.length === 0) throw new Error("secrets cannot be an empty array");
throw new Error("secrets cannot be an empty array");
for (const secret of value) { for (const secret of value) {
if (!secret.id) { if (!secret.id) {
throw new Error("Each secret must contain a ID property"); throw new Error("Each secret must contain a ID property");
@@ -187,15 +171,11 @@ router.patch(
}), }),
validateRequest, validateRequest,
requireAuth({ requireAuth({
acceptedAuthModes: [ acceptedAuthModes: [AUTH_MODE_JWT, AUTH_MODE_API_KEY, AUTH_MODE_SERVICE_TOKEN]
AUTH_MODE_JWT,
AUTH_MODE_API_KEY,
AUTH_MODE_SERVICE_TOKEN,
],
}), }),
requireSecretsAuth({ requireSecretsAuth({
acceptedRoles: [ADMIN, MEMBER], acceptedRoles: [ADMIN, MEMBER],
requiredPermissions: [PERMISSION_WRITE_SECRETS], requiredPermissions: [PERMISSION_WRITE_SECRETS]
}), }),
secretsController.updateSecrets secretsController.updateSecrets
); );
@@ -210,8 +190,7 @@ router.delete(
if (Array.isArray(value)) { if (Array.isArray(value)) {
// case: delete multiple secrets // case: delete multiple secrets
if (value.length === 0) if (value.length === 0) throw new Error("secrets cannot be an empty array");
throw new Error("secrets cannot be an empty array");
return value.every((id: string) => typeof id === "string"); return value.every((id: string) => typeof id === "string");
} }
@@ -221,15 +200,11 @@ router.delete(
.isEmpty(), .isEmpty(),
validateRequest, validateRequest,
requireAuth({ requireAuth({
acceptedAuthModes: [ acceptedAuthModes: [AUTH_MODE_JWT, AUTH_MODE_API_KEY, AUTH_MODE_SERVICE_TOKEN]
AUTH_MODE_JWT,
AUTH_MODE_API_KEY,
AUTH_MODE_SERVICE_TOKEN,
],
}), }),
requireSecretsAuth({ requireSecretsAuth({
acceptedRoles: [ADMIN, MEMBER], acceptedRoles: [ADMIN, MEMBER],
requiredPermissions: [PERMISSION_WRITE_SECRETS], requiredPermissions: [PERMISSION_WRITE_SECRETS]
}), }),
secretsController.deleteSecrets secretsController.deleteSecrets
); );
+93
View File
@@ -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<string, unknown>
) => {
const headers: Record<string, string> = {};
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
}
}
}))
]);
};
+1
View File
@@ -1,2 +1,3 @@
export const EVENT_PUSH_SECRETS = "pushSecrets"; export const EVENT_PUSH_SECRETS = "pushSecrets";
export const EVENT_PULL_SECRETS = "pullSecrets"; export const EVENT_PULL_SECRETS = "pullSecrets";
export const EVENT_START_INTEGRATION = "startIntegration";
+36
View File
@@ -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=<timestamp>;<signature>`. 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": ""
}
```
Binary file not shown.

After

Width:  |  Height:  |  Size: 129 KiB

+1
View File
@@ -114,6 +114,7 @@
"documentation/platform/project", "documentation/platform/project",
"documentation/platform/folder", "documentation/platform/folder",
"documentation/platform/secret-reference", "documentation/platform/secret-reference",
"documentation/platform/webhooks",
"documentation/platform/pit-recovery", "documentation/platform/pit-recovery",
"documentation/platform/secret-versioning", "documentation/platform/secret-versioning",
"documentation/platform/audit-logs", "documentation/platform/audit-logs",
+11
View File
@@ -44,6 +44,7 @@
"classnames": "^2.3.1", "classnames": "^2.3.1",
"cookies": "^0.8.0", "cookies": "^0.8.0",
"cva": "npm:class-variance-authority@^0.4.0", "cva": "npm:class-variance-authority@^0.4.0",
"dayjs": "^1.11.9",
"framer-motion": "^6.2.3", "framer-motion": "^6.2.3",
"fs": "^0.0.2", "fs": "^0.0.2",
"gray-matter": "^4.0.3", "gray-matter": "^4.0.3",
@@ -10571,6 +10572,11 @@
"integrity": "sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==", "integrity": "sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==",
"dev": true "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": { "node_modules/debug": {
"version": "4.3.4", "version": "4.3.4",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz",
@@ -30438,6 +30444,11 @@
"integrity": "sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==", "integrity": "sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==",
"dev": true "dev": true
}, },
"dayjs": {
"version": "1.11.9",
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.9.tgz",
"integrity": "sha512-QvzAURSbQ0pKdIye2txOzNaHmxtUBXerpY0FJsFXUMKbIZeFm5ht1LS/jFsrncjnmtv8HsG0W2g6c0zUjZWmpA=="
},
"debug": { "debug": {
"version": "4.3.4", "version": "4.3.4",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz",
+1
View File
@@ -52,6 +52,7 @@
"classnames": "^2.3.1", "classnames": "^2.3.1",
"cookies": "^0.8.0", "cookies": "^0.8.0",
"cva": "npm:class-variance-authority@^0.4.0", "cva": "npm:class-variance-authority@^0.4.0",
"dayjs": "^1.11.9",
"framer-motion": "^6.2.3", "framer-motion": "^6.2.3",
"fs": "^0.0.2", "fs": "^0.0.2",
"gray-matter": "^4.0.3", "gray-matter": "^4.0.3",
@@ -251,6 +251,10 @@
} }
}, },
"settings": { "settings": {
"webhooks": {
"title": "Webhooks",
"description": "Manage webhooks to setup deployment hooks for your various integrations."
},
"members": { "members": {
"title": "Project Members", "title": "Project Members",
"description": "This page shows the members of the selected project, and allows you to modify their permissions." "description": "This page shows the members of the selected project, and allows you to modify their permissions."
+17 -6
View File
@@ -61,7 +61,8 @@ const buttonVariants = cva(
{ {
colorSchema: "primary", colorSchema: "primary",
variant: "star", 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", colorSchema: "primary",
@@ -76,12 +77,14 @@ const buttonVariants = cva(
{ {
colorSchema: "primary", colorSchema: "primary",
variant: "outline_bg", 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", colorSchema: "secondary",
variant: "star", 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", colorSchema: "danger",
@@ -163,13 +166,13 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
type="button" type="button"
className={twMerge( className={twMerge(
buttonVariants({ buttonVariants({
className,
colorSchema, colorSchema,
size, size,
variant, variant,
isRounded, isRounded,
isDisabled, isDisabled,
isFullWidth isFullWidth,
className
}) })
)} )}
disabled={isDisabled} disabled={isDisabled}
@@ -193,7 +196,15 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
> >
{leftIcon} {leftIcon}
</div> </div>
<span className={twMerge("transition-all", isFullWidth ? "w-full" : "w-min", loadingToggleClass)}>{children}</span> <span
className={twMerge(
"transition-all",
isFullWidth ? "w-full" : "w-min",
loadingToggleClass
)}
>
{children}
</span>
<div <div
className={twMerge( className={twMerge(
"inline-flex shrink-0 cursor-pointer items-center justify-center transition-all", "inline-flex shrink-0 cursor-pointer items-center justify-center transition-all",
@@ -2,7 +2,7 @@ import { ReactNode } from "react";
import * as TooltipPrimitive from "@radix-ui/react-tooltip"; import * as TooltipPrimitive from "@radix-ui/react-tooltip";
import { twMerge } from "tailwind-merge"; import { twMerge } from "tailwind-merge";
export type TooltipProps = { export type TooltipProps = Omit<TooltipPrimitive.TooltipContentProps, "open" | "content"> & {
children: ReactNode; children: ReactNode;
content?: ReactNode; content?: ReactNode;
isOpen?: boolean; isOpen?: boolean;
@@ -10,7 +10,7 @@ export type TooltipProps = {
asChild?: boolean; asChild?: boolean;
onOpenChange?: (isOpen: boolean) => void; onOpenChange?: (isOpen: boolean) => void;
defaultOpen?: boolean; defaultOpen?: boolean;
} & Omit<TooltipPrimitive.TooltipContentProps, "open">; };
export const Tooltip = ({ export const Tooltip = ({
children, children,
+1
View File
@@ -13,4 +13,5 @@ export * from "./serviceTokens";
export * from "./subscriptions"; export * from "./subscriptions";
export * from "./tags"; export * from "./tags";
export * from "./users"; export * from "./users";
export * from "./webhooks";
export * from "./workspace"; export * from "./workspace";
+1
View File
@@ -8,6 +8,7 @@ export type { CreateServiceTokenDTO, ServiceToken } from "./serviceTokens/types"
export type { SubscriptionPlan } from "./subscriptions/types"; export type { SubscriptionPlan } from "./subscriptions/types";
export type { WsTag } from "./tags/types"; export type { WsTag } from "./tags/types";
export type { AddUserToWsDTO, AddUserToWsRes, OrgUser, User } from "./users/types"; export type { AddUserToWsDTO, AddUserToWsRes, OrgUser, User } from "./users/types";
export type { TWebhook } from "./webhooks/types";
export type { export type {
CreateEnvironmentDTO, CreateEnvironmentDTO,
CreateWorkspaceDTO, CreateWorkspaceDTO,
@@ -0,0 +1,2 @@
export { useCreateWebhook, useDeleteWebhook, useTestWebhook, useUpdateWebhook } from "./mutation";
export { useGetWebhooks } from "./query";
@@ -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));
}
});
};
+26
View File
@@ -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)
});
+36
View File
@@ -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;
};
@@ -36,7 +36,7 @@ import {
UpgradePlanModal UpgradePlanModal
} from "@app/components/v2"; } from "@app/components/v2";
import { leaveConfirmDefaultMessage } from "@app/const"; 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 { useLeaveConfirm, usePopUp, useToggle } from "@app/hooks";
import { import {
useBatchSecretsOp, useBatchSecretsOp,
@@ -342,7 +342,7 @@ export const DashboardPage = ({ envFromTop }: { envFromTop: string }) => {
const onAppendSecret = () => { const onAppendSecret = () => {
setSearchFilter(""); setSearchFilter("");
append(DEFAULT_SECRET_VALUE) append(DEFAULT_SECRET_VALUE);
}; };
const onSaveSecret = async ({ secrets: userSec = [], isSnapshotMode }: FormData) => { const onSaveSecret = async ({ secrets: userSec = [], isSnapshotMode }: FormData) => {
@@ -364,6 +364,10 @@ export const DashboardPage = ({ envFromTop }: { envFromTop: string }) => {
); );
// type check // type check
if (!selectedEnv?.slug) return; if (!selectedEnv?.slug) return;
if (batchedSecret.length === 0) {
reset();
return;
}
try { try {
await batchSecretOp({ await batchSecretOp({
requests: batchedSecret, requests: batchedSecret,
@@ -905,7 +909,11 @@ export const DashboardPage = ({ envFromTop }: { envFromTop: string }) => {
<UpgradePlanModal <UpgradePlanModal
isOpen={popUp.upgradePlan.isOpen} isOpen={popUp.upgradePlan.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("upgradePlan", isOpen)} onOpenChange={(isOpen) => 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"
}
/> />
)} )}
</div> </div>
@@ -1,18 +1,59 @@
import { Fragment } from "react";
import { useTranslation } from "react-i18next"; 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 = () => { export const ProjectSettingsPage = () => {
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<div className="flex justify-center bg-bunker-800 text-white w-full"> <div className="flex h-full w-full justify-center bg-bunker-800 px-6 text-white">
<div className="max-w-7xl w-full px-6"> <div className="w-full max-w-screen-lg">
<div className="mt-6 mb-6"> <div className="relative right-5 ml-4">
<p className="text-3xl font-semibold text-gray-200"> <NavHeader pageName={t("settings.project.title")} isProjectRelated />
{t("settings.project.title")}
</p>
</div> </div>
<ProjectTabGroup /> <div className="my-8">
<p className="text-3xl font-semibold text-gray-200">{t("settings.project.title")}</p>
</div>
<Tab.Group>
<Tab.List className="mb-6 w-full border-b-2 border-mineshaft-800">
{tabs.map((tab) => (
<Tab as={Fragment} key={tab.key}>
{({ selected }) => (
<button
type="button"
className={`w-30 p-4 font-semibold outline-none ${
selected ? "border-b-2 border-white text-white" : "text-mineshaft-400"
}`}
>
{tab.name}
</button>
)}
</Tab>
))}
</Tab.List>
<Tab.Panels>
<Tab.Panel>
<ProjectGeneralTab />
</Tab.Panel>
<Tab.Panel>
<ProjectServiceTokensTab />
</Tab.Panel>
<Tab.Panel>
<WebhooksTab />
</Tab.Panel>
</Tab.Panels>
</Tab.Group>
</div> </div>
</div> </div>
); );
@@ -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 (
<Tab.Group>
<Tab.List className="mb-6 border-b-2 border-mineshaft-800 w-full">
{tabs.map((tab) => (
<Tab as={Fragment} key={tab.key}>
{({ selected }) => (
<button
type="button"
className={`w-30 py-2 mx-2 mr-4 font-medium text-sm outline-none ${selected ? "border-b border-white text-white" : "text-mineshaft-400"}`}
>
{tab.name}
</button>
)}
</Tab>
))}
</Tab.List>
<Tab.Panels>
<Tab.Panel>
<ProjectGeneralTab />
</Tab.Panel>
<Tab.Panel>
<ProjectServiceTokensTab />
</Tab.Panel>
</Tab.Panels>
</Tab.Group>
);
}
@@ -1 +0,0 @@
export { ProjectTabGroup } from "./ProjectTabGroup";
@@ -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<typeof formSchema>;
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<TFormSchema>({
resolver: yupResolver(formSchema)
});
useEffect(() => {
if (!isOpen) {
reset();
}
}, [isOpen]);
return (
<Modal isOpen={isOpen} onOpenChange={onOpenChange}>
<ModalContent title="Create a new webhook">
<form onSubmit={handleSubmit(onCreateWebhook)}>
<div>
<Controller
control={control}
name="environment"
defaultValue={environments?.[0]?.slug}
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
<FormControl
label="Environment"
isRequired
errorText={error?.message}
isError={Boolean(error)}
>
<Select
defaultValue={field.value}
{...field}
onValueChange={(e) => onChange(e)}
className="w-full"
>
{environments.map(({ name, slug }) => (
<SelectItem value={slug} key={slug}>
{name}
</SelectItem>
))}
</Select>
</FormControl>
)}
/>
<FormControl
label="Secret Path"
isRequired
isError={Boolean(errors?.secretPath)}
errorText={errors?.secretPath?.message}
>
<Input placeholder="/, /**/*" {...register("secretPath")} />
</FormControl>
<FormControl
label="Secret Key"
isError={Boolean(errors?.webhookSecretKey)}
errorText={errors?.webhookSecretKey?.message}
helperText="To generate webhook signature for verification"
>
<Input
placeholder="Provided during webhook setup"
{...register("webhookSecretKey")}
/>
</FormControl>
<FormControl
label="Webhook URL"
isRequired
isError={Boolean(errors?.webhookUrl)}
errorText={errors?.webhookUrl?.message}
>
<Input {...register("webhookUrl")} />
</FormControl>
</div>
<div className="mt-8 flex items-center">
<Button
className="mr-4"
type="submit"
isDisabled={isSubmitting}
isLoading={isSubmitting}
>
Create
</Button>
<ModalClose asChild>
<Button variant="plain" colorSchema="secondary">
Cancel
</Button>
</ModalClose>
</div>
</form>
</ModalContent>
</Modal>
);
};
@@ -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 (
<div className="mb-6 max-w-screen-lg rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="flex justify-between">
<p className="text-xl font-semibold text-mineshaft-100">{t("settings.webhooks.title")}</p>
<Button
onClick={() => handlePopUpOpen("addWebhook")}
leftIcon={<FontAwesomeIcon icon={faPlus} />}
>
Create
</Button>
</div>
<p className="mb-8 text-gray-400">{t("settings.webhooks.description")}</p>
<div>
<TableContainer>
<Table>
<THead>
<Tr>
<Td>URL</Td>
<Td>Environment</Td>
<Td>Secret Path</Td>
<Td>Status</Td>
<Td className="text-right">Action</Td>
</Tr>
</THead>
<TBody>
{isWebhooksLoading && <TableSkeleton columns={5} key="webhooks-loading" />}
{!isWebhooksLoading && webhooks && webhooks?.length === 0 && (
<Tr>
<Td colSpan={5}>
<EmptyState title="No webhooks found" icon={faPlug} />
</Td>
</Tr>
)}
{!isWebhooksLoading &&
webhooks?.map(
({
_id: id,
url,
environment,
secretPath,
lastStatus,
isDisabled,
updatedAt,
lastRunErrorMessage
}) => (
<Tr key={id}>
<Td className="max-w-xs overflow-hidden text-ellipsis hover:overflow-auto hover:break-all">
{url}
</Td>
<Td>{environment}</Td>
<Td>{secretPath}</Td>
<Td>
{!lastStatus ? (
"-"
) : (
<div className="inline-flex w-min items-center rounded bg-mineshaft-600 px-2 py-0.5 text-sm">
{lastStatus}{" "}
<Tooltip
content={
<div className="text-xs">
<div>
Updated At: {dayjs(updatedAt).format("YYYY-MM-DD, hh:mm A")}
</div>
{lastRunErrorMessage && (
<div className="mt-2 text-red">
Error: {lastRunErrorMessage}
</div>
)}
</div>
}
>
<FontAwesomeIcon
className={`ml-1 ${
lastStatus === "failed" ? "text-red" : "text-green"
}`}
icon={faInfoCircle}
/>
</Tooltip>
</div>
)}
</Td>
<Td>
<div className="flex items-center justify-end space-x-2">
<Button
variant="star"
size="xs"
onClick={() => handleWebhookTest(id)}
isDisabled={
isTestWebhookSubmitting && testWebhookVars?.webhookId === id
}
isLoading={isTestWebhookSubmitting && testWebhookVars?.webhookId === id}
>
Test
</Button>
<Button
variant="outline_bg"
size="xs"
onClick={() => handleWebhookDisable(id, !isDisabled)}
isDisabled={
isUpdateWebhookSubmitting && updateWebhookVars?.webhookId === id
}
isLoading={
isUpdateWebhookSubmitting && updateWebhookVars?.webhookId === id
}
>
{isDisabled ? "Enable" : "Disable"}
</Button>
<Button
variant="outline_bg"
className="border-red-800 bg-red-800 hover:border-red-700 hover:bg-red-700"
colorSchema="danger"
size="xs"
onClick={() => handlePopUpOpen("deleteWebhook", id)}
>
Delete
</Button>
</div>
</Td>
</Tr>
)
)}
</TBody>
</Table>
</TableContainer>
</div>
<AddWebhookForm
environments={currentWorkspace?.environments}
isOpen={popUp?.addWebhook?.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("addWebhook", isOpen)}
onCreateWebhook={handleWebhookCreate}
/>
<DeleteActionModal
isOpen={popUp.deleteWebhook.isOpen}
deleteKey="remove"
title="Are you sure you want to delete this webhook?"
onChange={(isOpen) => handlePopUpToggle("deleteWebhook", isOpen)}
onClose={() => handlePopUpClose("deleteWebhook")}
onDeleteApproved={handleWebhookDelete}
/>
</div>
);
};
@@ -0,0 +1 @@
export { WebhooksTab } from "./WebhooksTab";
@@ -4,6 +4,5 @@ export { E2EESection } from "./E2EESection";
export { EnvironmentSection } from "./EnvironmentSection"; export { EnvironmentSection } from "./EnvironmentSection";
export { ProjectIndexSecretsSection } from "./ProjectIndexSecretsSection"; export { ProjectIndexSecretsSection } from "./ProjectIndexSecretsSection";
export { ProjectNameChangeSection } from "./ProjectNameChangeSection"; export { ProjectNameChangeSection } from "./ProjectNameChangeSection";
export { ProjectTabGroup } from "./ProjectTabGroup";
export { SecretTagsSection } from "./SecretTagsSection"; export { SecretTagsSection } from "./SecretTagsSection";
export { ServiceTokenSection } from "./ServiceTokenSection"; export { ServiceTokenSection } from "./ServiceTokenSection";