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

View File

@@ -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
};

View File

@@ -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
});
};

View File

@@ -80,7 +80,8 @@ export const pushSecrets = async (req: Request, res: Response) => {
EventService.handleEvent({
event: eventPushSecrets({
workspaceId: new Types.ObjectId(workspaceId),
environment
environment,
secretPath: "/"
})
});

View File

@@ -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
});
};

View File

@@ -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,

View File

@@ -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
});
};

View File

@@ -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
});
};

View File

@@ -1,5 +1,4 @@
import { eventPushSecrets } from "./secret"
import { eventPushSecrets } from "./secret";
import { eventStartIntegration } from "./integration";
export {
eventPushSecrets,
}
export { eventPushSecrets, eventStartIntegration };

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: {}
};
};

View File

@@ -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 };

View File

@@ -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;
}
};
};

View File

@@ -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();

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;

View File

@@ -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
};

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;

View File

@@ -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
);

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
}
}
}))
]);
};

View File

@@ -1,2 +1,3 @@
export const EVENT_PUSH_SECRETS = "pushSecrets";
export const EVENT_PULL_SECRETS = "pullSecrets";
export const EVENT_PULL_SECRETS = "pullSecrets";
export const EVENT_START_INTEGRATION = "startIntegration";

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": ""
}
```

BIN
docs/images/webhooks.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 129 KiB

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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."

View File

@@ -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<HTMLButtonElement, ButtonProps>(
type="button"
className={twMerge(
buttonVariants({
className,
colorSchema,
size,
variant,
isRounded,
isDisabled,
isFullWidth
isFullWidth,
className
})
)}
disabled={isDisabled}
@@ -193,7 +196,15 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
>
{leftIcon}
</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
className={twMerge(
"inline-flex shrink-0 cursor-pointer items-center justify-center transition-all",

View File

@@ -2,7 +2,7 @@ import { ReactNode } from "react";
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
import { twMerge } from "tailwind-merge";
export type TooltipProps = {
export type TooltipProps = Omit<TooltipPrimitive.TooltipContentProps, "open" | "content"> & {
children: ReactNode;
content?: ReactNode;
isOpen?: boolean;
@@ -10,7 +10,7 @@ export type TooltipProps = {
asChild?: boolean;
onOpenChange?: (isOpen: boolean) => void;
defaultOpen?: boolean;
} & Omit<TooltipPrimitive.TooltipContentProps, "open">;
};
export const Tooltip = ({
children,

View File

@@ -13,4 +13,5 @@ export * from "./serviceTokens";
export * from "./subscriptions";
export * from "./tags";
export * from "./users";
export * from "./webhooks";
export * from "./workspace";

View File

@@ -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,

View File

@@ -0,0 +1,2 @@
export { useCreateWebhook, useDeleteWebhook, useTestWebhook, useUpdateWebhook } from "./mutation";
export { useGetWebhooks } from "./query";

View File

@@ -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));
}
});
};

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

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;
};

View File

@@ -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={<FontAwesomeIcon icon={faCodeCommit} />}
@@ -905,7 +909,11 @@ export const DashboardPage = ({ envFromTop }: { envFromTop: string }) => {
<UpgradePlanModal
isOpen={popUp.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>

View File

@@ -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 (
<div className="flex justify-center bg-bunker-800 text-white w-full">
<div className="max-w-7xl w-full px-6">
<div className="mt-6 mb-6">
<p className="text-3xl font-semibold text-gray-200">
{t("settings.project.title")}
</p>
<div className="flex h-full w-full justify-center bg-bunker-800 px-6 text-white">
<div className="w-full max-w-screen-lg">
<div className="relative right-5 ml-4">
<NavHeader pageName={t("settings.project.title")} isProjectRelated />
</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>
);

View File

@@ -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>
);
}

View File

@@ -1 +0,0 @@
export { ProjectTabGroup } from "./ProjectTabGroup";

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -0,0 +1 @@
export { WebhooksTab } from "./WebhooksTab";

View File

@@ -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";