Merge pull request #609 from akhilmhdh/feat/folders

Feat/folders
This commit is contained in:
Maidul Islam
2023-06-04 16:24:43 -07:00
committed by GitHub
51 changed files with 4889 additions and 3440 deletions
+23 -15
View File
@@ -29,7 +29,6 @@
"crypto-js": "^4.1.1",
"dotenv": "^16.0.1",
"express": "^4.18.1",
"express-async-errors": "^3.1.1",
"express-rate-limit": "^6.7.0",
"express-validator": "^6.14.2",
"handlebars": "^4.7.7",
@@ -42,6 +41,7 @@
"lodash": "^4.17.21",
"mongoose": "^6.10.5",
"node-cache": "^5.1.2",
"nanoid": "^3.3.6",
"nodemailer": "^6.8.0",
"passport": "^0.6.0",
"passport-google-oauth20": "^2.0.0",
@@ -5190,14 +5190,6 @@
"node": ">= 0.10.0"
}
},
"node_modules/express-async-errors": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/express-async-errors/-/express-async-errors-3.1.1.tgz",
"integrity": "sha512-h6aK1da4tpqWSbyCa3FxB/V6Ehd4EEB15zyQq9qe75OZBp0krinNKuH4rAY+S/U/2I36vdLAUFSjQJ+TFmODng==",
"peerDependencies": {
"express": "^4.16.2"
}
},
"node_modules/express-rate-limit": {
"version": "6.7.0",
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-6.7.0.tgz",
@@ -7432,6 +7424,23 @@
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
"integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w=="
},
"node_modules/nanoid": {
"version": "3.3.6",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.6.tgz",
"integrity": "sha512-BGcqMMJuToF7i1rt+2PWSNVnWIkGCU78jBG3RxO/bZlnZPK2Cmi2QaffxGO/2RvWi9sL+FAiRiXMgsyxQ1DIDA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"bin": {
"nanoid": "bin/nanoid.cjs"
},
"engines": {
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
"node_modules/natural-compare": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz",
@@ -16406,12 +16415,6 @@
}
}
},
"express-async-errors": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/express-async-errors/-/express-async-errors-3.1.1.tgz",
"integrity": "sha512-h6aK1da4tpqWSbyCa3FxB/V6Ehd4EEB15zyQq9qe75OZBp0krinNKuH4rAY+S/U/2I36vdLAUFSjQJ+TFmODng==",
"requires": {}
},
"express-rate-limit": {
"version": "6.7.0",
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-6.7.0.tgz",
@@ -18085,6 +18088,11 @@
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
"integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w=="
},
"nanoid": {
"version": "3.3.6",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.6.tgz",
"integrity": "sha512-BGcqMMJuToF7i1rt+2PWSNVnWIkGCU78jBG3RxO/bZlnZPK2Cmi2QaffxGO/2RvWi9sL+FAiRiXMgsyxQ1DIDA=="
},
"natural-compare": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz",
+1 -1
View File
@@ -20,7 +20,6 @@
"crypto-js": "^4.1.1",
"dotenv": "^16.0.1",
"express": "^4.18.1",
"express-async-errors": "^3.1.1",
"express-rate-limit": "^6.7.0",
"express-validator": "^6.14.2",
"handlebars": "^4.7.7",
@@ -33,6 +32,7 @@
"lodash": "^4.17.21",
"mongoose": "^6.10.5",
"node-cache": "^5.1.2",
"nanoid": "^3.3.6",
"nodemailer": "^6.8.0",
"passport": "^0.6.0",
"passport-google-oauth20": "^2.0.0",
@@ -1,107 +1,218 @@
import { Request, Response } from 'express';
import { Secret } from '../../models';
import Folder from '../../models/folder';
import { BadRequestError } from '../../utils/errors';
import { ROOT_FOLDER_PATH, getFolderPath, getParentPath, normalizePath, validateFolderName } from '../../utils/folder';
import { ADMIN, MEMBER } from '../../variables';
import { validateMembership } from '../../helpers/membership';
import { Request, Response } from "express";
import { Secret } from "../../models";
import Folder from "../../models/folder";
import { BadRequestError } from "../../utils/errors";
import {
appendFolder,
deleteFolderById,
getAllFolderIds,
searchByFolderIdWithDir,
searchByFolderId,
validateFolderName,
generateFolderId,
getParentFromFolderId,
} from "../../services/FolderService";
import { ADMIN, MEMBER } from "../../variables";
import { validateMembership } from "../../helpers/membership";
import { FolderVersion } from "../../ee/models";
import { EESecretService } from "../../ee/services";
// TODO
// verify workspace id/environment
export const createFolder = async (req: Request, res: Response) => {
const { workspaceId, environment, folderName, parentFolderId } = req.body
const { workspaceId, environment, folderName, parentFolderId } = req.body;
if (!validateFolderName(folderName)) {
throw BadRequestError({ message: "Folder name cannot contain spaces. Only underscore and dashes" })
throw BadRequestError({
message: "Folder name cannot contain spaces. Only underscore and dashes",
});
}
if (parentFolderId) {
const parentFolder = await Folder.find({ environment: environment, workspace: workspaceId, id: parentFolderId });
const folders = await Folder.findOne({
workspace: workspaceId,
environment,
}).lean();
// space has no folders initialized
if (!folders) {
const id = generateFolderId();
const folder = new Folder({
workspace: workspaceId,
environment,
nodes: {
id: "root",
name: "root",
version: 1,
children: [{ id, name: folderName, children: [], version: 1 }],
},
});
await folder.save();
const folderVersion = new FolderVersion({
workspace: workspaceId,
environment,
nodes: folder.nodes,
});
await folderVersion.save();
await EESecretService.takeSecretSnapshot({
workspaceId,
environment,
});
return res.json({ folder: { id, name: folderName } });
}
const folder = appendFolder(folders.nodes, { folderName, parentFolderId });
await Folder.findByIdAndUpdate(folders._id, folders);
const parentFolder = searchByFolderId(folders.nodes, parentFolderId);
const folderVersion = new FolderVersion({
workspace: workspaceId,
environment,
nodes: parentFolder,
});
await folderVersion.save();
await EESecretService.takeSecretSnapshot({
workspaceId,
environment,
folderId: parentFolderId,
});
return res.json({ folder });
};
export const updateFolderById = async (req: Request, res: Response) => {
const { folderId } = req.params;
const { name, workspaceId, environment } = req.body;
const folders = await Folder.findOne({ workspace: workspaceId, environment });
if (!folders) {
throw BadRequestError({ message: "The folder doesn't exist" });
}
// check that user is a member of the workspace
await validateMembership({
userId: req.user._id.toString(),
workspaceId,
acceptedRoles: [ADMIN, MEMBER],
});
const parentFolder = getParentFromFolderId(folders.nodes, folderId);
if (!parentFolder) {
throw BadRequestError({ message: "The parent folder doesn't exist" })
throw BadRequestError({ message: "The folder doesn't exist" });
}
const folder = parentFolder.children.find(({ id }) => id === folderId);
if (!folder) {
throw BadRequestError({ message: "The folder doesn't exist" });
}
let completePath = await getFolderPath(parentFolderId)
if (completePath == ROOT_FOLDER_PATH) {
completePath = ""
}
parentFolder.version += 1;
folder.name = name;
const currentFolderPath = completePath + "/" + folderName // construct new path with current folder to be created
const normalizedCurrentPath = normalizePath(currentFolderPath)
const normalizedParentPath = getParentPath(normalizedCurrentPath)
const existingFolder = await Folder.findOne({
name: folderName,
await Folder.findByIdAndUpdate(folders._id, folders);
const folderVersion = new FolderVersion({
workspace: workspaceId,
environment: environment,
parent: parentFolderId,
path: normalizedCurrentPath
environment,
nodes: parentFolder,
});
await folderVersion.save();
await EESecretService.takeSecretSnapshot({
workspaceId,
environment,
folderId: parentFolder.id,
});
if (existingFolder) {
return res.json(existingFolder)
}
const newFolder = new Folder({
name: folderName,
workspace: workspaceId,
environment: environment,
parent: parentFolderId,
path: normalizedCurrentPath,
parentPath: normalizedParentPath
return res.json({
message: "Successfully updated folder",
folder: { name: folder.name, id: folder.id },
});
await newFolder.save();
return res.json(newFolder)
}
};
export const deleteFolder = async (req: Request, res: Response) => {
const { folderId } = req.params
const queue: any[] = [folderId];
const { folderId } = req.params;
const { workspaceId, environment } = req.body;
const folder = await Folder.findById(folderId);
if (!folder) {
throw BadRequestError({ message: "The folder doesn't exist" })
const folders = await Folder.findOne({ workspace: workspaceId, environment });
if (!folders) {
throw BadRequestError({ message: "The folder doesn't exist" });
}
// check that user is a member of the workspace
await validateMembership({
userId: req.user._id.toString(),
workspaceId: folder.workspace as any,
acceptedRoles: [ADMIN, MEMBER]
workspaceId,
acceptedRoles: [ADMIN, MEMBER],
});
while (queue.length > 0) {
const currentFolderId = queue.shift();
const delOp = deleteFolderById(folders.nodes, folderId);
if (!delOp) {
throw BadRequestError({ message: "The folder doesn't exist" });
}
const { deletedNode: delFolder, parent: parentFolder } = delOp;
const childFolders = await Folder.find({ parent: currentFolderId });
for (const childFolder of childFolders) {
queue.push(childFolder._id);
parentFolder.version += 1;
const delFolderIds = getAllFolderIds(delFolder);
await Folder.findByIdAndUpdate(folders._id, folders);
const folderVersion = new FolderVersion({
workspace: workspaceId,
environment,
nodes: parentFolder,
});
await folderVersion.save();
if (delFolderIds.length) {
await Secret.deleteMany({
folder: { $in: delFolderIds.map(({ id }) => id) },
workspace: workspaceId,
environment,
});
}
await Secret.deleteMany({ folder: currentFolderId });
await EESecretService.takeSecretSnapshot({
workspaceId,
environment,
folderId: parentFolder.id,
});
await Folder.deleteOne({ _id: currentFolderId });
}
res.send()
}
res.send({ message: "successfully deleted folders", folders: delFolderIds });
};
// TODO: validate workspace
export const getFolderById = async (req: Request, res: Response) => {
const { folderId } = req.params
export const getFolders = async (req: Request, res: Response) => {
const { workspaceId, environment, parentFolderId } = req.query as {
workspaceId: string;
environment: string;
parentFolderId?: string;
};
const folder = await Folder.findById(folderId);
if (!folder) {
throw BadRequestError({ message: "The folder doesn't exist" })
const folders = await Folder.findOne({ workspace: workspaceId, environment });
if (!folders) {
res.send({ folders: [], dir: [] });
return;
}
// check that user is a member of the workspace
await validateMembership({
userId: req.user._id.toString(),
workspaceId: folder.workspace as any,
acceptedRoles: [ADMIN, MEMBER]
workspaceId,
acceptedRoles: [ADMIN, MEMBER],
});
res.send({ folder })
if (!parentFolderId) {
const rootFolders = folders.nodes.children.map(({ id, name }) => ({
id,
name,
}));
res.send({ folders: rootFolders });
return;
}
const folderBySearch = searchByFolderIdWithDir(folders.nodes, parentFolderId);
if (!folderBySearch) {
throw BadRequestError({ message: "The folder doesn't exist" });
}
const { folder, dir } = folderBySearch;
res.send({
folders: folder.children.map(({ id, name }) => ({ id, name })),
dir,
});
};
File diff suppressed because it is too large Load Diff
@@ -1,8 +1,8 @@
import { Request, Response } from 'express';
import * as Sentry from '@sentry/node';
import { Secret } from '../../../models';
import { SecretVersion } from '../../models';
import { EESecretService } from '../../services';
import { Request, Response } from "express";
import * as Sentry from "@sentry/node";
import { Secret } from "../../../models";
import { SecretVersion } from "../../models";
import { EESecretService } from "../../services";
/**
* Return secret versions for secret with id [secretId]
@@ -57,30 +57,32 @@ import { EESecretService } from '../../services';
*/
let secretVersions;
try {
const { secretId } = req.params;
const { secretId, workspaceId, environment, folderId } = req.params;
const offset: number = parseInt(req.query.offset as string);
const limit: number = parseInt(req.query.limit as string);
secretVersions = await SecretVersion.find({
secret: secretId
secret: secretId,
workspace: workspaceId,
environment,
folder: folderId,
})
.sort({ createdAt: -1 })
.skip(offset)
.limit(limit);
} catch (err) {
Sentry.setUser({ email: req.user.email });
Sentry.captureException(err);
return res.status(400).send({
message: 'Failed to get secret versions'
message: "Failed to get secret versions",
});
}
return res.status(200).send({
secretVersions
secretVersions,
});
}
};
/**
* Roll back secret with id [secretId] to version [version]
@@ -145,10 +147,10 @@ export const rollbackSecretVersion = async (req: Request, res: Response) => {
// validate secret version
const oldSecretVersion = await SecretVersion.findOne({
secret: secretId,
version
}).select('+secretBlindIndex')
version,
}).select("+secretBlindIndex");
if (!oldSecretVersion) throw new Error('Failed to find secret version');
if (!oldSecretVersion) throw new Error("Failed to find secret version");
const {
workspace,
@@ -162,8 +164,9 @@ export const rollbackSecretVersion = async (req: Request, res: Response) => {
secretValueCiphertext,
secretValueIV,
secretValueTag,
folder,
algorithm,
keyEncoding
keyEncoding,
} = oldSecretVersion;
// update secret
@@ -171,7 +174,7 @@ export const rollbackSecretVersion = async (req: Request, res: Response) => {
secretId,
{
$inc: {
version: 1
version: 1,
},
workspace,
type,
@@ -184,15 +187,16 @@ export const rollbackSecretVersion = async (req: Request, res: Response) => {
secretValueCiphertext,
secretValueIV,
secretValueTag,
folderId: folder,
algorithm,
keyEncoding
keyEncoding,
},
{
new: true
new: true,
}
);
if (!secret) throw new Error('Failed to find and update secret');
if (!secret) throw new Error("Failed to find and update secret");
// add new secret version
await new SecretVersion({
@@ -210,24 +214,26 @@ export const rollbackSecretVersion = async (req: Request, res: Response) => {
secretValueCiphertext,
secretValueIV,
secretValueTag,
folder,
algorithm,
keyEncoding
keyEncoding,
}).save();
// take secret snapshot
await EESecretService.takeSecretSnapshot({
workspaceId: secret.workspace
workspaceId: secret.workspace,
environment,
folderId: folder,
});
} catch (err) {
Sentry.setUser({ email: req.user.email });
Sentry.captureException(err);
return res.status(400).send({
message: 'Failed to roll back secret version'
message: "Failed to roll back secret version",
});
}
return res.status(200).send({
secret
secret,
});
}
};
@@ -1,6 +1,10 @@
import { Request, Response } from 'express';
import * as Sentry from '@sentry/node';
import { SecretSnapshot } from '../../models';
import { Request, Response } from "express";
import * as Sentry from "@sentry/node";
import {
ISecretVersion,
SecretSnapshot,
TFolderRootVersionSchema,
} from "../../models";
/**
* Return secret snapshot with id [secretSnapshotId]
@@ -13,21 +17,32 @@ export const getSecretSnapshot = async (req: Request, res: Response) => {
try {
const { secretSnapshotId } = req.params;
secretSnapshot = await SecretSnapshot
.findById(secretSnapshotId)
.populate('secretVersions');
if (!secretSnapshot) throw new Error('Failed to find secret snapshot');
secretSnapshot = await SecretSnapshot.findById(secretSnapshotId)
.lean()
.populate<{ secretVersions: ISecretVersion[] }>("secretVersions")
.populate<{ folderVersion: TFolderRootVersionSchema }>("folderVersion");
if (!secretSnapshot) throw new Error("Failed to find secret snapshot");
} catch (err) {
Sentry.setUser({ email: req.user.email });
Sentry.captureException(err);
return res.status(400).send({
message: 'Failed to get secret snapshot'
message: "Failed to get secret snapshot",
});
}
const folderId = secretSnapshot.folderId;
// to show only the folder required secrets
secretSnapshot.secretVersions = secretSnapshot.secretVersions.filter(
({ folder }) => folder === folderId
);
secretSnapshot.folderVersion =
secretSnapshot?.folderVersion?.nodes?.children?.map(({ id, name }) => ({
id,
name,
})) as any;
return res.status(200).send({
secretSnapshot
secretSnapshot,
});
}
};
@@ -1,24 +1,29 @@
import { Request, Response } from 'express';
import * as Sentry from '@sentry/node';
import { Types } from 'mongoose';
import {
Secret
} from '../../../models';
import { Request, Response } from "express";
import * as Sentry from "@sentry/node";
import { PipelineStage, Types } from "mongoose";
import { Secret } from "../../../models";
import {
SecretSnapshot,
Log,
SecretVersion,
ISecretVersion
} from '../../models';
import { EESecretService } from '../../services';
import { getLatestSecretVersionIds } from '../../helpers/secretVersion';
ISecretVersion,
FolderVersion,
TFolderRootVersionSchema,
} from "../../models";
import { EESecretService } from "../../services";
import { getLatestSecretVersionIds } from "../../helpers/secretVersion";
import Folder, { TFolderSchema } from "../../../models/folder";
import { searchByFolderId } from "../../../services/FolderService";
/**
* Return secret snapshots for workspace with id [workspaceId]
* @param req
* @param res
*/
export const getWorkspaceSecretSnapshots = async (req: Request, res: Response) => {
export const getWorkspaceSecretSnapshots = async (
req: Request,
res: Response
) => {
/*
#swagger.summary = 'Return project secret snapshot ids'
#swagger.description = 'Return project secret snapshots ids'
@@ -67,54 +72,63 @@ import { getLatestSecretVersionIds } from '../../helpers/secretVersion';
let secretSnapshots;
try {
const { workspaceId } = req.params;
const { environment, folderId } = req.query;
const offset: number = parseInt(req.query.offset as string);
const limit: number = parseInt(req.query.limit as string);
secretSnapshots = await SecretSnapshot.find({
workspace: workspaceId
workspace: workspaceId,
environment,
folderId: folderId || "root",
})
.sort({ createdAt: -1 })
.skip(offset)
.limit(limit);
} catch (err) {
Sentry.setUser({ email: req.user.email });
Sentry.captureException(err);
return res.status(400).send({
message: 'Failed to get secret snapshots'
message: "Failed to get secret snapshots",
});
}
return res.status(200).send({
secretSnapshots
secretSnapshots,
});
}
};
/**
* Return count of secret snapshots for workspace with id [workspaceId]
* @param req
* @param res
*/
export const getWorkspaceSecretSnapshotsCount = async (req: Request, res: Response) => {
export const getWorkspaceSecretSnapshotsCount = async (
req: Request,
res: Response
) => {
let count;
try {
const { workspaceId } = req.params;
const { environment, folderId } = req.query;
count = await SecretSnapshot.countDocuments({
workspace: workspaceId
workspace: workspaceId,
environment,
folderId: folderId || "root",
});
} catch (err) {
Sentry.setUser({ email: req.user.email });
Sentry.captureException(err);
return res.status(400).send({
message: 'Failed to count number of secret snapshots'
message: "Failed to count number of secret snapshots",
});
}
return res.status(200).send({
count
count,
});
}
};
/**
* Rollback secret snapshot with id [secretSnapshotId] to version [version]
@@ -122,7 +136,10 @@ export const getWorkspaceSecretSnapshotsCount = async (req: Request, res: Respon
* @param res
* @returns
*/
export const rollbackWorkspaceSecretSnapshot = async (req: Request, res: Response) => {
export const rollbackWorkspaceSecretSnapshot = async (
req: Request,
res: Response
) => {
/*
#swagger.summary = 'Roll back project secrets to those captured in a secret snapshot version.'
#swagger.description = 'Roll back project secrets to those captured in a secret snapshot version.'
@@ -177,51 +194,192 @@ export const rollbackWorkspaceSecretSnapshot = async (req: Request, res: Respons
let secrets;
try {
const { workspaceId } = req.params;
const { version } = req.body;
const { version, environment, folderId = "root" } = req.body;
// validate secret snapshot
const secretSnapshot = await SecretSnapshot.findOne({
workspace: workspaceId,
version
}).populate<{ secretVersions: ISecretVersion[]}>({
path: 'secretVersions',
select: '+secretBlindIndex'
version,
environment,
folderId: folderId,
})
.populate<{ secretVersions: ISecretVersion[] }>({
path: "secretVersions",
select: "+secretBlindIndex",
})
.populate<{ folderVersion: TFolderRootVersionSchema }>("folderVersion");
if (!secretSnapshot) throw new Error("Failed to find secret snapshot");
const snapshotFolderTree = secretSnapshot.folderVersion;
const latestFolderTree = await Folder.findOne({
workspace: workspaceId,
environment,
});
if (!secretSnapshot) throw new Error('Failed to find secret snapshot');
const latestFolderVersion = await FolderVersion.findOne({
environment,
workspace: workspaceId,
"nodes.id": folderId,
}).sort({ "nodes.version": -1 });
// TODO: fix any
const oldSecretVersionsObj: any = secretSnapshot.secretVersions
.reduce((accumulator, s) => ({
...accumulator,
[`${s.secret.toString()}`]: s
}), {});
const oldSecretVersionsObj: Record<string, ISecretVersion> = {};
const secretIds: Types.ObjectId[] = [];
const folderIds: string[] = [folderId];
const latestSecretVersionIds = await getLatestSecretVersionIds({
secretIds: secretSnapshot.secretVersions.map((sv) => sv.secret)
secretSnapshot.secretVersions.forEach((snapSecVer) => {
oldSecretVersionsObj[snapSecVer.secret.toString()] = snapSecVer;
secretIds.push(snapSecVer.secret);
});
// TODO: fix any
const latestSecretVersions: any = (await SecretVersion.find({
_id: {
$in: latestSecretVersionIds.map((s) => s.versionId)
// the parent node from current latest one
// this will be modified according to the snapshot and latest snapshots
const newFolderTree =
latestFolderTree && searchByFolderId(latestFolderTree.nodes, folderId);
if (newFolderTree) {
newFolderTree.children = snapshotFolderTree?.nodes?.children || [];
const queue = [newFolderTree];
// a bfs algorithm in which we take the latest snapshots of all the folders in a level
while (queue.length) {
const groupByFolderId: Record<string, TFolderSchema> = {};
// the original queue is popped out completely to get what ever in a level
// subqueue is filled with all the children thus next level folders
// subQueue will then be transfered to the oriinal queue
const subQueue: TFolderSchema[] = [];
// get everything inside a level
while (queue.length) {
const folder = queue.pop() as TFolderSchema;
folder.children.forEach((el) => {
folderIds.push(el.id); // push ids and data into queu
subQueue.push(el);
// to modify the original tree very fast we keep a reference object
// key with folder id and pointing to the various nodes
groupByFolderId[el.id] = el;
});
}
}, 'secret version'))
.reduce((accumulator, s) => ({
// get latest snapshots of all the folder
const matchWsFoldersPipeline = {
$match: {
workspace: new Types.ObjectId(workspaceId),
environment,
folderId: {
$in: Object.keys(groupByFolderId),
},
},
};
const sortByFolderIdAndVersion: PipelineStage = {
$sort: { folderId: 1, version: -1 },
};
const pickLatestVersionOfEachFolder = {
$group: {
_id: "$folderId",
latestVersion: { $first: "$version" },
doc: {
$first: "$$ROOT",
},
},
};
const populateSecVersion = {
$lookup: {
from: SecretVersion.collection.name,
localField: "doc.secretVersions",
foreignField: "_id",
as: "doc.secretVersions",
},
};
const populateFolderVersion = {
$lookup: {
from: FolderVersion.collection.name,
localField: "doc.folderVersion",
foreignField: "_id",
as: "doc.folderVersion",
},
};
const unwindFolderVerField = {
$unwind: {
path: "$doc.folderVersion",
preserveNullAndEmptyArrays: true,
},
};
const latestSnapshotsByFolders: Array<{ doc: typeof secretSnapshot }> =
await SecretSnapshot.aggregate([
matchWsFoldersPipeline,
sortByFolderIdAndVersion,
pickLatestVersionOfEachFolder,
populateSecVersion,
populateFolderVersion,
unwindFolderVerField,
]);
// recursive snapshotting each level
latestSnapshotsByFolders.forEach((snap) => {
// mutate the folder tree to update the nodes to the latest version tree
// we are reconstructing the folder tree by latest snapshots here
if (groupByFolderId[snap.doc.folderId]) {
groupByFolderId[snap.doc.folderId].children =
snap.doc?.folderVersion?.nodes?.children || [];
}
// push all children of next level snapshots
if (snap.doc.folderVersion?.nodes?.children) {
queue.push(...snap.doc.folderVersion.nodes.children);
}
snap.doc.secretVersions.forEach((snapSecVer) => {
// record all the secrets
oldSecretVersionsObj[snapSecVer.secret.toString()] = snapSecVer;
secretIds.push(snapSecVer.secret);
});
});
queue.push(...subQueue);
}
}
// TODO: fix any
const latestSecretVersionIds = await getLatestSecretVersionIds({
secretIds,
});
// TODO: fix any
const latestSecretVersions: any = (
await SecretVersion.find(
{
_id: {
$in: latestSecretVersionIds.map((s) => s.versionId),
},
},
"secret version"
)
).reduce(
(accumulator, s) => ({
...accumulator,
[`${s.secret.toString()}`]: s
}), {});
[`${s.secret.toString()}`]: s,
}),
{}
);
const secDelQuery: Record<string, unknown> = {
workspace: workspaceId,
environment,
// undefined means root thus collect all secrets
};
if (folderId !== "root" && folderIds.length)
secDelQuery.folder = { $in: folderIds };
// delete existing secrets
await Secret.deleteMany({
workspace: workspaceId
await Secret.deleteMany(secDelQuery);
await Folder.deleteOne({
workspace: workspaceId,
environment,
});
// add secrets
secrets = await Secret.insertMany(
secretSnapshot.secretVersions.map((sv) => {
const secretId = sv.secret;
Object.keys(oldSecretVersionsObj).map((sv) => {
const {
secret: secretId,
workspace,
type,
user,
@@ -230,15 +388,16 @@ export const rollbackWorkspaceSecretSnapshot = async (req: Request, res: Respons
secretKeyCiphertext,
secretKeyIV,
secretKeyTag,
secretKeyHash,
secretValueCiphertext,
secretValueIV,
secretValueTag,
secretValueHash,
createdAt
} = oldSecretVersionsObj[secretId.toString()];
createdAt,
algorithm,
keyEncoding,
folder: secFolderId,
} = oldSecretVersionsObj[sv];
return ({
return {
_id: secretId,
version: latestSecretVersions[secretId.toString()].version + 1,
workspace,
@@ -249,22 +408,24 @@ export const rollbackWorkspaceSecretSnapshot = async (req: Request, res: Respons
secretKeyCiphertext,
secretKeyIV,
secretKeyTag,
secretKeyHash,
secretValueCiphertext,
secretValueIV,
secretValueTag,
secretValueHash,
secretCommentCiphertext: '',
secretCommentIV: '',
secretCommentTag: '',
createdAt
});
secretCommentCiphertext: "",
secretCommentIV: "",
secretCommentTag: "",
createdAt,
algorithm,
keyEncoding,
folder: secFolderId,
};
})
);
// add secret versions
const secretV = await SecretVersion.insertMany(
secrets.map(({
secrets.map(
({
_id,
version,
workspace,
@@ -275,11 +436,12 @@ export const rollbackWorkspaceSecretSnapshot = async (req: Request, res: Respons
secretKeyCiphertext,
secretKeyIV,
secretKeyTag,
secretKeyHash,
secretValueCiphertext,
secretValueIV,
secretValueTag,
secretValueHash
algorithm,
keyEncoding,
folder: secFolderId,
}) => ({
_id: new Types.ObjectId(),
secret: _id,
@@ -293,39 +455,64 @@ export const rollbackWorkspaceSecretSnapshot = async (req: Request, res: Respons
secretKeyCiphertext,
secretKeyIV,
secretKeyTag,
secretKeyHash,
secretValueCiphertext,
secretValueIV,
secretValueTag,
secretValueHash
}))
algorithm,
keyEncoding,
folder: secFolderId,
})
)
);
// update secret versions of restored secrets as not deleted
await SecretVersion.updateMany({
secret: {
$in: secretSnapshot.secretVersions.map((sv) => sv.secret)
}
}, {
isDeleted: false
if (newFolderTree && latestFolderTree) {
// save the updated folder tree to the present one
newFolderTree.version = (latestFolderVersion?.nodes?.version || 0) + 1;
latestFolderTree._id = new Types.ObjectId();
latestFolderTree.isNew = true;
await latestFolderTree.save();
// create new folder version
const newFolderVersion = new FolderVersion({
workspace: workspaceId,
environment,
nodes: newFolderTree,
});
await newFolderVersion.save();
}
// update secret versions of restored secrets as not deleted
await SecretVersion.updateMany(
{
secret: {
$in: Object.keys(oldSecretVersionsObj).map(
(sv) => oldSecretVersionsObj[sv].secret
),
},
},
{
isDeleted: false,
}
);
// take secret snapshot
await EESecretService.takeSecretSnapshot({
workspaceId: new Types.ObjectId(workspaceId)
workspaceId: new Types.ObjectId(workspaceId),
environment,
folderId,
});
} catch (err) {
Sentry.setUser({ email: req.user.email });
Sentry.captureException(err);
return res.status(400).send({
message: 'Failed to roll back secret snapshot'
message: "Failed to roll back secret snapshot",
});
}
return res.status(200).send({
secrets
secrets,
});
}
};
/**
* Return (audit) logs for workspace with id [workspaceId]
@@ -400,7 +587,7 @@ export const getWorkspaceLogs = async (req: Request, res: Response) => {
}
}
*/
let logs
let logs;
try {
const { workspaceId } = req.params;
@@ -413,30 +600,28 @@ export const getWorkspaceLogs = async (req: Request, res: Response) => {
logs = await Log.find({
workspace: workspaceId,
...(userId ? { user: userId } : {}),
...(
actionNames
...(actionNames
? {
actionNames: {
$in: actionNames.split(',')
$in: actionNames.split(","),
},
}
} : {}
)
: {}),
})
.sort({ createdAt: sortBy === 'recent' ? -1 : 1 })
.sort({ createdAt: sortBy === "recent" ? -1 : 1 })
.skip(offset)
.limit(limit)
.populate('actions')
.populate('user serviceAccount serviceTokenData');
.populate("actions")
.populate("user serviceAccount serviceTokenData");
} catch (err) {
Sentry.setUser({ email: req.user.email });
Sentry.captureException(err);
return res.status(400).send({
message: 'Failed to get workspace logs'
message: "Failed to get workspace logs",
});
}
return res.status(200).send({
logs
logs,
});
}
};
+25 -3
View File
@@ -1,6 +1,11 @@
import { Types } from "mongoose";
import { Secret, ISecret } from "../../models";
import { SecretSnapshot, SecretVersion, ISecretVersion } from "../models";
import {
SecretSnapshot,
SecretVersion,
ISecretVersion,
FolderVersion,
} from "../models";
/**
* Save a secret snapshot that is a copy of the current state of secrets in workspace with id
@@ -12,22 +17,31 @@ import { SecretSnapshot, SecretVersion, ISecretVersion } from "../models";
*/
const takeSecretSnapshotHelper = async ({
workspaceId,
environment,
folderId = "root",
}: {
workspaceId: Types.ObjectId;
environment: string;
folderId?: string;
}) => {
// get all folder ids
const secretIds = (
await Secret.find(
{
workspace: workspaceId,
environment,
folder: folderId,
},
"_id"
)
).lean()
).map((s) => s._id);
const latestSecretVersions = (
await SecretVersion.aggregate([
{
$match: {
environment,
workspace: new Types.ObjectId(workspaceId),
secret: {
$in: secretIds,
},
@@ -45,6 +59,11 @@ const takeSecretSnapshotHelper = async ({
},
]).exec()
).map((s) => s.versionId);
const latestFolderVersion = await FolderVersion.findOne({
environment,
workspace: workspaceId,
"nodes.id": folderId,
}).sort({ "nodes.version": -1 });
const latestSecretSnapshot = await SecretSnapshot.findOne({
workspace: workspaceId,
@@ -52,8 +71,11 @@ const takeSecretSnapshotHelper = async ({
const secretSnapshot = await new SecretSnapshot({
workspace: workspaceId,
environment,
version: latestSecretSnapshot ? latestSecretSnapshot.version + 1 : 1,
secretVersions: latestSecretVersions,
folderId,
folderVersion: latestFolderVersion,
}).save();
return secretSnapshot;
@@ -96,5 +118,5 @@ const markDeletedSecretVersionsHelper = async ({
export {
takeSecretSnapshotHelper,
addSecretVersionsHelper,
markDeletedSecretVersionsHelper
markDeletedSecretVersionsHelper,
};
+21 -31
View File
@@ -1,5 +1,5 @@
import { Types } from 'mongoose';
import { SecretVersion } from '../models';
import { Types } from "mongoose";
import { SecretVersion } from "../models";
/**
* Return latest secret versions for secrets with ids [secretIds]
@@ -8,39 +8,32 @@ import { SecretVersion } from '../models';
* @returns
*/
const getLatestSecretVersionIds = async ({
secretIds
secretIds,
}: {
secretIds: Types.ObjectId[];
}) => {
interface LatestSecretVersionId {
_id: Types.ObjectId;
version: number;
versionId: Types.ObjectId;
}
const latestSecretVersionIds = (await SecretVersion.aggregate([
const latestSecretVersionIds = await SecretVersion.aggregate([
{
$match: {
secret: {
$in: secretIds
}
}
$in: secretIds,
},
},
},
{
$group: {
_id: '$secret',
version: { $max: '$version' },
versionId: { $max: '$_id' } // id of latest secret version
}
_id: "$secret",
version: { $max: "$version" },
versionId: { $max: "$_id" }, // id of latest secret version
},
},
{
$sort: { version: -1 }
}
])
.exec());
$sort: { version: -1 },
},
]).exec();
return latestSecretVersionIds;
}
};
/**
* Return latest [n] secret versions for secrets with ids [secretIds]
@@ -51,13 +44,13 @@ const getLatestSecretVersionIds = async ({
*/
const getLatestNSecretSecretVersionIds = async ({
secretIds,
n
n,
}: {
secretIds: Types.ObjectId[];
n: number;
}) => {
// TODO: optimize query
const latestNSecretVersions = (await SecretVersion.aggregate([
const latestNSecretVersions = await SecretVersion.aggregate([
{
$match: {
secret: {
@@ -80,13 +73,10 @@ const getLatestNSecretSecretVersionIds = async ({
secret: "$_id",
versions: { $slice: ["$versions", n] },
},
}
]));
},
]);
return latestNSecretVersions;
}
};
export {
getLatestSecretVersionIds,
getLatestNSecretSecretVersionIds
}
export { getLatestSecretVersionIds, getLatestNSecretSecretVersionIds };
+60
View File
@@ -0,0 +1,60 @@
import { model, Schema, Types } from "mongoose";
export type TFolderRootVersionSchema = {
_id: Types.ObjectId;
workspace: Types.ObjectId;
environment: string;
nodes: TFolderVersionSchema;
};
export type TFolderVersionSchema = {
id: string;
name: string;
version: number;
children: TFolderVersionSchema[];
};
const folderVersionSchema = new Schema<TFolderVersionSchema>({
id: {
required: true,
type: String,
default: "root",
},
name: {
required: true,
type: String,
default: "root",
},
version: {
required: true,
type: Number,
default: 1,
},
});
folderVersionSchema.add({ children: [folderVersionSchema] });
const folderRootVersionSchema = new Schema<TFolderRootVersionSchema>(
{
workspace: {
type: Schema.Types.ObjectId,
ref: "Workspace",
required: true,
},
environment: {
type: String,
required: true,
},
nodes: folderVersionSchema,
},
{
timestamps: true,
}
);
const FolderVersion = model<TFolderRootVersionSchema>(
"FolderVersion",
folderRootVersionSchema
);
export default FolderVersion;
+9 -6
View File
@@ -1,15 +1,18 @@
import SecretSnapshot, { ISecretSnapshot } from './secretSnapshot';
import SecretVersion, { ISecretVersion } from './secretVersion';
import Log, { ILog } from './log';
import Action, { IAction } from './action';
import SecretSnapshot, { ISecretSnapshot } from "./secretSnapshot";
import SecretVersion, { ISecretVersion } from "./secretVersion";
import FolderVersion, { TFolderRootVersionSchema } from "./folderVersion";
import Log, { ILog } from "./log";
import Action, { IAction } from "./action";
export {
SecretSnapshot,
ISecretSnapshot,
SecretVersion,
ISecretVersion,
FolderVersion,
TFolderRootVersionSchema,
Log,
ILog,
Action,
IAction
}
IAction,
};
+31 -10
View File
@@ -1,33 +1,54 @@
import { Schema, model, Types } from 'mongoose';
import { Schema, model, Types } from "mongoose";
export interface ISecretSnapshot {
workspace: Types.ObjectId;
environment: string;
folderId: string | "root";
version: number;
secretVersions: Types.ObjectId[];
folderVersion: Types.ObjectId;
}
const secretSnapshotSchema = new Schema<ISecretSnapshot>(
{
workspace: {
type: Schema.Types.ObjectId,
ref: 'Workspace',
required: true
ref: "Workspace",
required: true,
},
environment: {
type: String,
required: true,
},
folderId: {
type: String,
default: "root",
},
version: {
type: Number,
required: true
default: 1,
required: true,
},
secretVersions: [{
secretVersions: [
{
type: Schema.Types.ObjectId,
ref: 'SecretVersion',
required: true
}]
ref: "SecretVersion",
required: true,
},
],
folderVersion: {
type: Schema.Types.ObjectId,
ref: "FolderVersion",
},
},
{
timestamps: true
timestamps: true,
}
);
const SecretSnapshot = model<ISecretSnapshot>('SecretSnapshot', secretSnapshotSchema);
const SecretSnapshot = model<ISecretSnapshot>(
"SecretSnapshot",
secretSnapshotSchema
);
export default SecretSnapshot;
+43 -32
View File
@@ -1,11 +1,11 @@
import { Schema, model, Types } from 'mongoose';
import { Schema, model, Types } from "mongoose";
import {
SECRET_SHARED,
SECRET_PERSONAL,
ALGORITHM_AES_256_GCM,
ENCODING_SCHEME_UTF8,
ENCODING_SCHEME_BASE64
} from '../../variables';
ENCODING_SCHEME_BASE64,
} from "../../variables";
export interface ISecretVersion {
_id: Types.ObjectId;
@@ -23,93 +23,104 @@ export interface ISecretVersion {
secretValueCiphertext: string;
secretValueIV: string;
secretValueTag: string;
algorithm: 'aes-256-gcm';
keyEncoding: 'utf8' | 'base64';
algorithm: "aes-256-gcm";
keyEncoding: "utf8" | "base64";
createdAt: string;
folder?: string;
}
const secretVersionSchema = new Schema<ISecretVersion>(
{
secret: { // could be deleted
secret: {
// could be deleted
type: Schema.Types.ObjectId,
ref: 'Secret',
required: true
ref: "Secret",
required: true,
},
version: {
type: Number,
default: 1,
required: true
required: true,
},
workspace: {
type: Schema.Types.ObjectId,
ref: 'Workspace',
required: true
ref: "Workspace",
required: true,
},
type: {
type: String,
enum: [SECRET_SHARED, SECRET_PERSONAL],
required: true
required: true,
},
user: {
// user associated with the personal secret
type: Schema.Types.ObjectId,
ref: 'User'
ref: "User",
},
environment: {
type: String,
required: true
required: true,
},
isDeleted: { // consider removing field
isDeleted: {
// consider removing field
type: Boolean,
default: false,
required: true
required: true,
},
secretBlindIndex: {
type: String,
select: false
select: false,
},
secretKeyCiphertext: {
type: String,
required: true
required: true,
},
secretKeyIV: {
type: String, // symmetric
required: true
required: true,
},
secretKeyTag: {
type: String, // symmetric
required: true
required: true,
},
secretValueCiphertext: {
type: String,
required: true
required: true,
},
secretValueIV: {
type: String, // symmetric
required: true
required: true,
},
secretValueTag: {
type: String, // symmetric
required: true
required: true,
},
algorithm: { // the encryption algorithm used
algorithm: {
// the encryption algorithm used
type: String,
enum: [ALGORITHM_AES_256_GCM],
required: true
required: true,
default: ALGORITHM_AES_256_GCM,
},
keyEncoding: {
type: String,
enum: [
ENCODING_SCHEME_UTF8,
ENCODING_SCHEME_BASE64
],
required: true
enum: [ENCODING_SCHEME_UTF8, ENCODING_SCHEME_BASE64],
required: true,
default: ENCODING_SCHEME_UTF8,
},
folder: {
type: String,
required: true,
},
},
{
timestamps: true
timestamps: true,
}
);
const SecretVersion = model<ISecretVersion>('SecretVersion', secretVersionSchema);
const SecretVersion = model<ISecretVersion>(
"SecretVersion",
secretVersionSchema
);
export default SecretVersion;
+36 -30
View File
@@ -1,74 +1,80 @@
import express from 'express';
import express from "express";
const router = express.Router();
import {
requireAuth,
requireWorkspaceAuth,
validateRequest
} from '../../../middleware';
import { param, query, body } from 'express-validator';
import { ADMIN, MEMBER } from '../../../variables';
import { workspaceController } from '../../controllers/v1';
validateRequest,
} from "../../../middleware";
import { param, query, body } from "express-validator";
import { ADMIN, MEMBER } from "../../../variables";
import { workspaceController } from "../../controllers/v1";
router.get(
'/:workspaceId/secret-snapshots',
"/:workspaceId/secret-snapshots",
requireAuth({
acceptedAuthModes: ['jwt', 'apiKey']
acceptedAuthModes: ["jwt", "apiKey"],
}),
requireWorkspaceAuth({
acceptedRoles: [ADMIN, MEMBER],
locationWorkspaceId: 'params'
locationWorkspaceId: "params",
}),
param('workspaceId').exists().trim(),
query('offset').exists().isInt(),
query('limit').exists().isInt(),
param("workspaceId").exists().trim(),
query("environment").isString().exists().trim(),
query("folderId").default("root").isString().trim(),
query("offset").exists().isInt(),
query("limit").exists().isInt(),
validateRequest,
workspaceController.getWorkspaceSecretSnapshots
);
router.get(
'/:workspaceId/secret-snapshots/count',
"/:workspaceId/secret-snapshots/count",
requireAuth({
acceptedAuthModes: ['jwt']
acceptedAuthModes: ["jwt"],
}),
requireWorkspaceAuth({
acceptedRoles: [ADMIN, MEMBER],
locationWorkspaceId: 'params'
locationWorkspaceId: "params",
}),
param('workspaceId').exists().trim(),
param("workspaceId").exists().trim(),
query("environment").isString().exists().trim(),
query("folderId").default("root").isString().trim(),
validateRequest,
workspaceController.getWorkspaceSecretSnapshotsCount
);
router.post(
'/:workspaceId/secret-snapshots/rollback',
"/:workspaceId/secret-snapshots/rollback",
requireAuth({
acceptedAuthModes: ['jwt', 'apiKey']
acceptedAuthModes: ["jwt", "apiKey"],
}),
requireWorkspaceAuth({
acceptedRoles: [ADMIN, MEMBER],
locationWorkspaceId: 'params'
locationWorkspaceId: "params",
}),
param('workspaceId').exists().trim(),
body('version').exists().isInt(),
param("workspaceId").exists().trim(),
body("environment").isString().exists().trim(),
query("folderId").default("root").isString().exists().trim(),
body("version").exists().isInt(),
validateRequest,
workspaceController.rollbackWorkspaceSecretSnapshot
);
router.get(
'/:workspaceId/logs',
"/:workspaceId/logs",
requireAuth({
acceptedAuthModes: ['jwt', 'apiKey']
acceptedAuthModes: ["jwt", "apiKey"],
}),
requireWorkspaceAuth({
acceptedRoles: [ADMIN, MEMBER],
locationWorkspaceId: 'params'
locationWorkspaceId: "params",
}),
param('workspaceId').exists().trim(),
query('offset').exists().isInt(),
query('limit').exists().isInt(),
query('sortBy'),
query('userId'),
query('actionNames'),
param("workspaceId").exists().trim(),
query("offset").exists().isInt(),
query("limit").exists().isInt(),
query("sortBy"),
query("userId"),
query("actionNames"),
validateRequest,
workspaceController.getWorkspaceLogs
);
+15 -8
View File
@@ -3,7 +3,7 @@ import { ISecretVersion } from '../models';
import {
takeSecretSnapshotHelper,
addSecretVersionsHelper,
markDeletedSecretVersionsHelper
markDeletedSecretVersionsHelper,
} from '../helpers/secret';
import EELicenseService from './EELicenseService';
@@ -11,7 +11,6 @@ import EELicenseService from './EELicenseService';
* Class to handle Enterprise Edition secret actions
*/
class EESecretService {
/**
* Save a secret snapshot that is a copy of the current state of secrets in workspace with id
* [workspaceId] under a new snapshot with incremented version under the
@@ -22,12 +21,20 @@ class EESecretService {
* @returns {SecretSnapshot} secretSnapshot - new secret snpashot
*/
static async takeSecretSnapshot({
workspaceId
workspaceId,
environment,
folderId,
}: {
workspaceId: Types.ObjectId;
environment: string;
folderId?: string;
}) {
if (!EELicenseService.isLicenseValid) return;
return await takeSecretSnapshotHelper({ workspaceId });
return await takeSecretSnapshotHelper({
workspaceId,
environment,
folderId,
});
}
/**
@@ -37,13 +44,13 @@ class EESecretService {
* @returns {SecretVersion[]} newSecretVersions - new secret versions
*/
static async addSecretVersions({
secretVersions
secretVersions,
}: {
secretVersions: ISecretVersion[];
}) {
if (!EELicenseService.isLicenseValid) return;
return await addSecretVersionsHelper({
secretVersions
secretVersions,
});
}
@@ -54,13 +61,13 @@ class EESecretService {
* @param {ObjectId[]} obj.secretIds - secret ids
*/
static async markDeletedSecretVersions({
secretIds
secretIds,
}: {
secretIds: Types.ObjectId[];
}) {
if (!EELicenseService.isLicenseValid) return;
await markDeletedSecretVersionsHelper({
secretIds
secretIds,
});
}
}
+21 -29
View File
@@ -1,13 +1,7 @@
import * as Sentry from '@sentry/node';
import { Types } from 'mongoose';
import {
Membership,
Key
} from '../models';
import {
MembershipNotFoundError,
BadRequestError
} from '../utils/errors';
import { Membership, Key } from '../models';
import { MembershipNotFoundError, BadRequestError } from '../utils/errors';
/**
* Validate that user with id [userId] is a member of workspace with id [workspaceId]
@@ -22,28 +16,31 @@ const validateMembership = async ({
workspaceId,
acceptedRoles,
}: {
userId: Types.ObjectId;
workspaceId: Types.ObjectId;
userId: Types.ObjectId | string;
workspaceId: Types.ObjectId | string;
acceptedRoles?: Array<'admin' | 'member'>;
}) => {
const membership = await Membership.findOne({
user: userId,
workspace: workspaceId
}).populate("workspace");
workspace: workspaceId,
}).populate('workspace');
if (!membership) {
throw MembershipNotFoundError({ message: 'Failed to find workspace membership' });
throw MembershipNotFoundError({
message: 'Failed to find workspace membership',
});
}
if (acceptedRoles) {
if (!acceptedRoles.includes(membership.role)) {
throw BadRequestError({ message: 'Failed authorization for membership role' });
throw BadRequestError({
message: 'Failed authorization for membership role',
});
}
}
return membership;
}
};
/**
* Return membership matching criteria specified in query [queryObj]
@@ -74,7 +71,7 @@ const findMembership = async (queryObj: any) => {
const addMemberships = async ({
userIds,
workspaceId,
roles
roles,
}: {
userIds: string[];
workspaceId: string;
@@ -87,15 +84,15 @@ const addMemberships = async ({
filter: {
user: userId,
workspace: workspaceId,
role: roles[idx]
role: roles[idx],
},
update: {
user: userId,
workspace: workspaceId,
role: roles[idx]
role: roles[idx],
},
upsert: true,
},
upsert: true
}
};
});
@@ -116,7 +113,7 @@ const deleteMembership = async ({ membershipId }: { membershipId: string }) => {
let deletedMembership;
try {
deletedMembership = await Membership.findOneAndDelete({
_id: membershipId
_id: membershipId,
});
// delete keys associated with the membership
@@ -124,7 +121,7 @@ const deleteMembership = async ({ membershipId }: { membershipId: string }) => {
// case: membership had a registered user
await Key.deleteMany({
receiver: deletedMembership.user,
workspace: deletedMembership.workspace
workspace: deletedMembership.workspace,
});
}
} catch (err) {
@@ -136,9 +133,4 @@ const deleteMembership = async ({ membershipId }: { membershipId: string }) => {
return deletedMembership;
};
export {
validateMembership,
addMemberships,
findMembership,
deleteMembership
};
export { validateMembership, addMemberships, findMembership, deleteMembership };
+8 -8
View File
@@ -1,5 +1,5 @@
import { Types } from "mongoose";
import { Secret, ISecret, Membership } from "../models";
import { Secret, ISecret } from "../models";
import { EESecretService, EELogService } from "../ee/services";
import { IAction, SecretVersion } from "../ee/models";
import {
@@ -12,8 +12,6 @@ import {
ALGORITHM_AES_256_GCM,
ENCODING_SCHEME_UTF8,
} from "../variables";
import _ from "lodash";
import { BadRequestError, UnauthorizedRequestError } from "../utils/errors";
interface V1PushSecret {
ciphertextKey: string;
@@ -197,7 +195,7 @@ const v1PushSecrets = async ({
secretValueTag: newSecret.tagValue,
secretValueHash: newSecret.hashValue,
algorithm: ALGORITHM_AES_256_GCM,
keyEncoding: ENCODING_SCHEME_UTF8
keyEncoding: ENCODING_SCHEME_UTF8,
});
}),
});
@@ -230,7 +228,7 @@ const v1PushSecrets = async ({
secretCommentTag: s.tagComment,
secretCommentHash: s.hashComment,
algorithm: ALGORITHM_AES_256_GCM,
keyEncoding: ENCODING_SCHEME_UTF8
keyEncoding: ENCODING_SCHEME_UTF8,
};
if (toAdd[idx].type === "personal") {
@@ -261,7 +259,7 @@ const v1PushSecrets = async ({
secretValueTag,
secretValueHash,
algorithm,
keyEncoding
keyEncoding,
}) =>
new SecretVersion({
secret: _id,
@@ -280,7 +278,7 @@ const v1PushSecrets = async ({
secretValueTag,
secretValueHash,
algorithm,
keyEncoding
keyEncoding,
})
),
});
@@ -289,6 +287,7 @@ const v1PushSecrets = async ({
// (EE) take a secret snapshot
await EESecretService.takeSecretSnapshot({
workspaceId: new Types.ObjectId(workspaceId),
environment,
});
};
@@ -491,7 +490,7 @@ const v2PushSecrets = async ({
secret: secretDocument._id,
isDeleted: false,
algorithm: ALGORITHM_AES_256_GCM,
keyEncoding: ENCODING_SCHEME_UTF8
keyEncoding: ENCODING_SCHEME_UTF8,
});
}),
});
@@ -508,6 +507,7 @@ const v2PushSecrets = async ({
// (EE) take a secret snapshot
await EESecretService.takeSecretSnapshot({
workspaceId: new Types.ObjectId(workspaceId),
environment,
});
// (EE) create (audit) log
+144 -129
View File
@@ -4,19 +4,15 @@ import {
GetSecretsParams,
GetSecretParams,
UpdateSecretParams,
DeleteSecretParams
DeleteSecretParams,
} from '../interfaces/services/SecretService';
import {
Secret,
ISecret,
SecretBlindIndexData,
} from '../models';
import { Secret, ISecret, SecretBlindIndexData } from '../models';
import { SecretVersion } from '../ee/models';
import {
BadRequestError,
SecretNotFoundError,
SecretBlindIndexDataNotFoundError,
InternalServerError
InternalServerError,
} from '../utils/errors';
import {
SECRET_PERSONAL,
@@ -27,23 +23,20 @@ import {
ACTION_DELETE_SECRETS,
ALGORITHM_AES_256_GCM,
ENCODING_SCHEME_UTF8,
ENCODING_SCHEME_BASE64
ENCODING_SCHEME_BASE64,
} from '../variables';
import crypto from 'crypto';
import * as argon2 from 'argon2';
import {
encryptSymmetric128BitHexKeyUTF8,
decryptSymmetric128BitHexKeyUTF8
decryptSymmetric128BitHexKeyUTF8,
} from '../utils/crypto';
import { getEncryptionKey, client, getRootEncryptionKey } from '../config';
import { TelemetryService } from '../services';
import {
EESecretService,
EELogService
} from '../ee/services';
import { EESecretService, EELogService } from '../ee/services';
import {
getAuthDataPayloadIdObj,
getAuthDataPayloadUserObj
getAuthDataPayloadUserObj,
} from '../utils/auth';
/**
@@ -53,11 +46,10 @@ import {
* @param {Types.ObjectId} obj.workspaceId
*/
const createSecretBlindIndexDataHelper = async ({
workspaceId
workspaceId,
}: {
workspaceId: Types.ObjectId;
}) => {
// initialize random blind index salt for workspace
const salt = crypto.randomBytes(16).toString('base64');
@@ -68,7 +60,7 @@ const createSecretBlindIndexDataHelper = async ({
const {
ciphertext: encryptedSaltCiphertext,
iv: saltIV,
tag: saltTag
tag: saltTag,
} = client.encryptSymmetric(salt, rootEncryptionKey);
return await new SecretBlindIndexData({
@@ -77,16 +69,16 @@ const createSecretBlindIndexDataHelper = async ({
saltIV,
saltTag,
algorithm: ALGORITHM_AES_256_GCM,
keyEncoding: ENCODING_SCHEME_BASE64
keyEncoding: ENCODING_SCHEME_BASE64,
}).save();
} else {
const {
ciphertext: encryptedSaltCiphertext,
iv: saltIV,
tag: saltTag
tag: saltTag,
} = encryptSymmetric128BitHexKeyUTF8({
plaintext: salt,
key: encryptionKey
key: encryptionKey,
});
return await new SecretBlindIndexData({
@@ -95,10 +87,10 @@ const createSecretBlindIndexDataHelper = async ({
saltIV,
saltTag,
algorithm: ALGORITHM_AES_256_GCM,
keyEncoding: ENCODING_SCHEME_UTF8
keyEncoding: ENCODING_SCHEME_UTF8,
}).save();
}
}
};
/**
* Get secret blind index salt for workspace with id [workspaceId]
@@ -107,41 +99,46 @@ const createSecretBlindIndexDataHelper = async ({
* @returns
*/
const getSecretBlindIndexSaltHelper = async ({
workspaceId
workspaceId,
}: {
workspaceId: Types.ObjectId;
}) => {
const encryptionKey = await getEncryptionKey();
const rootEncryptionKey = await getRootEncryptionKey();
const secretBlindIndexData = await SecretBlindIndexData.findOne({
workspace: workspaceId
workspace: workspaceId,
}).select('+algorithm +keyEncoding');
if (!secretBlindIndexData) throw SecretBlindIndexDataNotFoundError();
if (rootEncryptionKey && secretBlindIndexData.keyEncoding === ENCODING_SCHEME_BASE64) {
if (
rootEncryptionKey &&
secretBlindIndexData.keyEncoding === ENCODING_SCHEME_BASE64
) {
return client.decryptSymmetric(
secretBlindIndexData.encryptedSaltCiphertext,
rootEncryptionKey,
secretBlindIndexData.saltIV,
secretBlindIndexData.saltTag
);
} else if (encryptionKey && secretBlindIndexData.keyEncoding === ENCODING_SCHEME_UTF8) {
} else if (
encryptionKey &&
secretBlindIndexData.keyEncoding === ENCODING_SCHEME_UTF8
) {
// decrypt workspace salt
return decryptSymmetric128BitHexKeyUTF8({
ciphertext: secretBlindIndexData.encryptedSaltCiphertext,
iv: secretBlindIndexData.saltIV,
tag: secretBlindIndexData.saltTag,
key: encryptionKey
key: encryptionKey,
});
}
throw InternalServerError({
message: 'Failed to obtain workspace salt needed for secret blind indexing'
message: 'Failed to obtain workspace salt needed for secret blind indexing',
});
}
};
/**
* Generate blind index for secret with name [secretName]
@@ -152,25 +149,26 @@ const getSecretBlindIndexSaltHelper = async ({
*/
const generateSecretBlindIndexWithSaltHelper = async ({
secretName,
salt
salt,
}: {
secretName: string;
salt: string;
}) => {
// generate secret blind index
const secretBlindIndex = (await argon2.hash(secretName, {
const secretBlindIndex = (
await argon2.hash(secretName, {
type: argon2.argon2id,
salt: Buffer.from(salt, 'base64'),
saltLength: 16, // default 16 bytes
memoryCost: 65536, // default pool of 64 MiB per thread.
hashLength: 32,
parallelism: 1,
raw: true
})).toString('base64');
raw: true,
})
).toString('base64');
return secretBlindIndex;
}
};
/**
* Generate blind index for secret with name [secretName]
@@ -181,15 +179,14 @@ const getSecretBlindIndexSaltHelper = async ({
*/
const generateSecretBlindIndexHelper = async ({
secretName,
workspaceId
workspaceId,
}: {
secretName: string;
workspaceId: Types.ObjectId;
}) => {
// check if workspace blind index data exists
const secretBlindIndexData = await SecretBlindIndexData.findOne({
workspace: workspaceId
workspace: workspaceId,
});
if (!secretBlindIndexData) throw SecretBlindIndexDataNotFoundError();
@@ -199,16 +196,16 @@ const generateSecretBlindIndexHelper = async ({
ciphertext: secretBlindIndexData.encryptedSaltCiphertext,
iv: secretBlindIndexData.saltIV,
tag: secretBlindIndexData.saltTag,
key: await getEncryptionKey()
key: await getEncryptionKey(),
});
const secretBlindIndex = await generateSecretBlindIndexWithSaltHelper({
secretName,
salt
salt,
});
return secretBlindIndex;
}
};
/**
* Create secret with name [secretName]
@@ -234,22 +231,24 @@ const createSecretHelper = async ({
secretValueTag,
secretCommentCiphertext,
secretCommentIV,
secretCommentTag
secretCommentTag,
folderId,
}: CreateSecretParams) => {
const secretBlindIndex = await generateSecretBlindIndexHelper({
secretName,
workspaceId: new Types.ObjectId(workspaceId)
workspaceId: new Types.ObjectId(workspaceId),
});
const exists = await Secret.exists({
secretBlindIndex,
workspace: new Types.ObjectId(workspaceId),
type,
...(type === SECRET_PERSONAL ? getAuthDataPayloadUserObj(authData) : {})
...(type === SECRET_PERSONAL ? getAuthDataPayloadUserObj(authData) : {}),
});
if (exists) throw BadRequestError({
message: 'Failed to create secret that already exists'
if (exists)
throw BadRequestError({
message: 'Failed to create secret that already exists',
});
if (type === SECRET_PERSONAL) {
@@ -259,11 +258,13 @@ const createSecretHelper = async ({
const exists = await Secret.exists({
secretBlindIndex,
workspace: new Types.ObjectId(workspaceId),
type: SECRET_SHARED
type: SECRET_SHARED,
});
if (!exists) throw BadRequestError({
message: 'Failed to create personal secret override for no corresponding shared secret'
if (!exists)
throw BadRequestError({
message:
'Failed to create personal secret override for no corresponding shared secret',
});
}
@@ -284,8 +285,9 @@ const createSecretHelper = async ({
secretCommentCiphertext,
secretCommentIV,
secretCommentTag,
folder: folderId,
algorithm: ALGORITHM_AES_256_GCM,
keyEncoding: ENCODING_SCHEME_UTF8
keyEncoding: ENCODING_SCHEME_UTF8,
}).save();
const secretVersion = new SecretVersion({
@@ -304,12 +306,12 @@ const createSecretHelper = async ({
secretValueIV,
secretValueTag,
algorithm: ALGORITHM_AES_256_GCM,
keyEncoding: ENCODING_SCHEME_UTF8
keyEncoding: ENCODING_SCHEME_UTF8,
});
// // (EE) add version for new secret
// (EE) add version for new secret
await EESecretService.addSecretVersions({
secretVersions: [secretVersion]
secretVersions: [secretVersion],
});
// (EE) create (audit) log
@@ -317,20 +319,23 @@ const createSecretHelper = async ({
name: ACTION_ADD_SECRETS,
...getAuthDataPayloadIdObj(authData),
workspaceId,
secretIds: [secret._id]
secretIds: [secret._id],
});
action && await EELogService.createLog({
action &&
(await EELogService.createLog({
...getAuthDataPayloadIdObj(authData),
workspaceId,
actions: [action],
channel: authData.authChannel,
ipAddress: authData.authIP
});
ipAddress: authData.authIP,
}));
// (EE) take a secret snapshot
await EESecretService.takeSecretSnapshot({
workspaceId
workspaceId,
environment,
folderId,
});
const postHogClient = await TelemetryService.getPostHogClient();
@@ -339,20 +344,20 @@ const createSecretHelper = async ({
postHogClient.capture({
event: 'secrets added',
distinctId: await TelemetryService.getDistinctId({
authData
authData,
}),
properties: {
numberOfSecrets: 1,
environment,
workspaceId,
channel: authData.authChannel,
userAgent: authData.authUserAgent
}
userAgent: authData.authUserAgent,
},
});
}
return secret;
}
};
/**
* Get secrets for workspace with id [workspaceId] and environment [environment]
@@ -365,7 +370,7 @@ const createSecretHelper = async ({
const getSecretsHelper = async ({
workspaceId,
environment,
authData
authData,
}: GetSecretsParams) => {
let secrets: ISecret[] = [];
@@ -374,34 +379,37 @@ const getSecretsHelper = async ({
workspace: new Types.ObjectId(workspaceId),
environment,
type: SECRET_PERSONAL,
...getAuthDataPayloadUserObj(authData)
...getAuthDataPayloadUserObj(authData),
});
// concat with shared secrets
secrets = secrets.concat(await Secret.find({
secrets = secrets.concat(
await Secret.find({
workspace: new Types.ObjectId(workspaceId),
environment,
type: SECRET_SHARED,
secretBlindIndex: {
$nin: secrets.map((secret) => secret.secretBlindIndex)
}
}));
$nin: secrets.map((secret) => secret.secretBlindIndex),
},
})
);
// (EE) create (audit) log
const action = await EELogService.createAction({
name: ACTION_READ_SECRETS,
...getAuthDataPayloadIdObj(authData),
workspaceId,
secretIds: secrets.map((secret) => secret._id)
secretIds: secrets.map((secret) => secret._id),
});
action && await EELogService.createLog({
action &&
(await EELogService.createLog({
...getAuthDataPayloadIdObj(authData),
workspaceId,
actions: [action],
channel: authData.authChannel,
ipAddress: authData.authIP
});
ipAddress: authData.authIP,
}));
const postHogClient = await TelemetryService.getPostHogClient();
@@ -409,20 +417,20 @@ const getSecretsHelper = async ({
postHogClient.capture({
event: 'secrets pulled',
distinctId: await TelemetryService.getDistinctId({
authData
authData,
}),
properties: {
numberOfSecrets: secrets.length,
environment,
workspaceId,
channel: authData.authChannel,
userAgent: authData.authUserAgent
}
userAgent: authData.authUserAgent,
},
});
}
return secrets;
}
};
/**
* Get secret with name [secretName]
@@ -439,11 +447,11 @@ const getSecretHelper = async ({
workspaceId,
environment,
type,
authData
authData,
}: GetSecretParams) => {
const secretBlindIndex = await generateSecretBlindIndexHelper({
secretName,
workspaceId: new Types.ObjectId(workspaceId)
workspaceId: new Types.ObjectId(workspaceId),
});
let secret: ISecret | null = null;
@@ -453,7 +461,7 @@ const getSecretHelper = async ({
workspace: new Types.ObjectId(workspaceId),
environment,
type: type ?? SECRET_PERSONAL,
...(type === SECRET_PERSONAL ? getAuthDataPayloadUserObj(authData) : {})
...(type === SECRET_PERSONAL ? getAuthDataPayloadUserObj(authData) : {}),
});
if (!secret) {
@@ -463,7 +471,7 @@ const getSecretHelper = async ({
secretBlindIndex,
workspace: new Types.ObjectId(workspaceId),
environment,
type: SECRET_SHARED
type: SECRET_SHARED,
});
}
@@ -474,16 +482,17 @@ const getSecretHelper = async ({
name: ACTION_READ_SECRETS,
...getAuthDataPayloadIdObj(authData),
workspaceId,
secretIds: [secret._id]
secretIds: [secret._id],
});
action && await EELogService.createLog({
action &&
(await EELogService.createLog({
...getAuthDataPayloadIdObj(authData),
workspaceId,
actions: [action],
channel: authData.authChannel,
ipAddress: authData.authIP
});
ipAddress: authData.authIP,
}));
const postHogClient = await TelemetryService.getPostHogClient();
@@ -491,20 +500,20 @@ const getSecretHelper = async ({
postHogClient.capture({
event: 'secrets pull',
distinctId: await TelemetryService.getDistinctId({
authData
authData,
}),
properties: {
numberOfSecrets: 1,
environment,
workspaceId,
channel: authData.authChannel,
userAgent: authData.authUserAgent
}
userAgent: authData.authUserAgent,
},
});
}
return secret;
}
};
/**
* Update secret with name [secretName]
@@ -527,11 +536,11 @@ const updateSecretHelper = async ({
authData,
secretValueCiphertext,
secretValueIV,
secretValueTag
secretValueTag,
}: UpdateSecretParams) => {
const secretBlindIndex = await generateSecretBlindIndexHelper({
secretName,
workspaceId: new Types.ObjectId(workspaceId)
workspaceId: new Types.ObjectId(workspaceId),
});
let secret: ISecret | null = null;
@@ -543,16 +552,16 @@ const updateSecretHelper = async ({
secretBlindIndex,
workspace: new Types.ObjectId(workspaceId),
environment,
type
type,
},
{
secretValueCiphertext,
secretValueIV,
secretValueTag,
$inc: { version: 1 }
$inc: { version: 1 },
},
{
new: true
new: true,
}
);
} else {
@@ -564,16 +573,16 @@ const updateSecretHelper = async ({
workspace: new Types.ObjectId(workspaceId),
environment,
type,
...getAuthDataPayloadUserObj(authData)
...getAuthDataPayloadUserObj(authData),
},
{
secretValueCiphertext,
secretValueIV,
secretValueTag,
$inc: { version: 1 }
$inc: { version: 1 },
},
{
new: true
new: true,
}
);
}
@@ -596,12 +605,12 @@ const updateSecretHelper = async ({
secretValueIV,
secretValueTag,
algorithm: ALGORITHM_AES_256_GCM,
keyEncoding: ENCODING_SCHEME_UTF8
keyEncoding: ENCODING_SCHEME_UTF8,
});
// (EE) add version for new secret
await EESecretService.addSecretVersions({
secretVersions: [secretVersion]
secretVersions: [secretVersion],
});
// (EE) create (audit) log
@@ -609,20 +618,23 @@ const updateSecretHelper = async ({
name: ACTION_UPDATE_SECRETS,
...getAuthDataPayloadIdObj(authData),
workspaceId,
secretIds: [secret._id]
secretIds: [secret._id],
});
action && await EELogService.createLog({
action &&
(await EELogService.createLog({
...getAuthDataPayloadIdObj(authData),
workspaceId,
actions: [action],
channel: authData.authChannel,
ipAddress: authData.authIP
});
ipAddress: authData.authIP,
}));
// (EE) take a secret snapshot
await EESecretService.takeSecretSnapshot({
workspaceId
workspaceId,
environment,
folderId: secret?.folder,
});
const postHogClient = await TelemetryService.getPostHogClient();
@@ -631,20 +643,20 @@ const updateSecretHelper = async ({
postHogClient.capture({
event: 'secrets modified',
distinctId: await TelemetryService.getDistinctId({
authData
authData,
}),
properties: {
numberOfSecrets: 1,
environment,
workspaceId,
channel: authData.authChannel,
userAgent: authData.authUserAgent
}
userAgent: authData.authUserAgent,
},
});
}
return secret;
}
};
/**
* Delete secret with name [secretName]
@@ -661,11 +673,11 @@ const deleteSecretHelper = async ({
workspaceId,
environment,
type,
authData
authData,
}: DeleteSecretParams) => {
const secretBlindIndex = await generateSecretBlindIndexHelper({
secretName,
workspaceId: new Types.ObjectId(workspaceId)
workspaceId: new Types.ObjectId(workspaceId),
});
let secrets: ISecret[] = [];
@@ -675,20 +687,20 @@ const deleteSecretHelper = async ({
secrets = await Secret.find({
secretBlindIndex,
workspaceId: new Types.ObjectId(workspaceId),
environment
environment,
});
secret = await Secret.findOneAndDelete({
secretBlindIndex,
workspaceId: new Types.ObjectId(workspaceId),
environment,
type
type,
});
await Secret.deleteMany({
secretBlindIndex,
workspaceId: new Types.ObjectId(workspaceId),
environment
environment,
});
} else {
secret = await Secret.findOneAndDelete({
@@ -696,7 +708,7 @@ const deleteSecretHelper = async ({
workspaceId: new Types.ObjectId(workspaceId),
environment,
type,
...getAuthDataPayloadUserObj(authData)
...getAuthDataPayloadUserObj(authData),
});
if (secret) {
@@ -707,7 +719,7 @@ const deleteSecretHelper = async ({
if (!secret) throw SecretNotFoundError();
await EESecretService.markDeletedSecretVersions({
secretIds: secrets.map((secret) => secret._id)
secretIds: secrets.map((secret) => secret._id),
});
// (EE) create (audit) log
@@ -715,21 +727,24 @@ const deleteSecretHelper = async ({
name: ACTION_DELETE_SECRETS,
...getAuthDataPayloadIdObj(authData),
workspaceId,
secretIds: secrets.map((secret) => secret._id)
secretIds: secrets.map((secret) => secret._id),
});
// (EE) take a secret snapshot
action && await EELogService.createLog({
action &&
(await EELogService.createLog({
...getAuthDataPayloadIdObj(authData),
workspaceId,
actions: [action],
channel: authData.authChannel,
ipAddress: authData.authIP
});
ipAddress: authData.authIP,
}));
// (EE) take a secret snapshot
await EESecretService.takeSecretSnapshot({
workspaceId
workspaceId,
environment,
folderId: secret?.folder,
});
const postHogClient = await TelemetryService.getPostHogClient();
@@ -738,23 +753,23 @@ const deleteSecretHelper = async ({
postHogClient.capture({
event: 'secrets deleted',
distinctId: await TelemetryService.getDistinctId({
authData
authData,
}),
properties: {
numberOfSecrets: secrets.length,
environment,
workspaceId,
channel: authData.authChannel,
userAgent: authData.authUserAgent
}
userAgent: authData.authUserAgent,
},
});
}
return ({
return {
secrets,
secret
});
}
secret,
};
};
export {
createSecretBlindIndexDataHelper,
@@ -765,5 +780,5 @@ export {
getSecretsHelper,
getSecretHelper,
updateSecretHelper,
deleteSecretHelper
}
deleteSecretHelper,
};
+17 -20
View File
@@ -1,8 +1,6 @@
import dotenv from "dotenv";
import dotenv from 'dotenv';
dotenv.config();
import express from 'express';
// eslint-disable-next-line @typescript-eslint/no-var-requires
require('express-async-errors');
import helmet from 'helmet';
import cors from 'cors';
import { DatabaseService } from './services';
@@ -21,7 +19,7 @@ import {
secretSnapshot as eeSecretSnapshotRouter,
action as eeActionRouter,
organizations as eeOrganizationsRouter,
cloudProducts as eeCloudProductsRouter
cloudProducts as eeCloudProductsRouter,
} from './ee/routes/v1';
import {
signup as v1SignupRouter,
@@ -41,7 +39,7 @@ import {
stripe as v1StripeRouter,
integration as v1IntegrationRouter,
integrationAuth as v1IntegrationAuthRouter,
secretsFolder as v1SecretsFolder
secretsFolder as v1SecretsFolder,
} from './routes/v1';
import {
signup as v2SignupRouter,
@@ -61,17 +59,13 @@ 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';
import { RouteNotFoundError } from './utils/errors';
import { requestErrorHandler } from './middleware/requestErrorHandler';
import {
getNodeEnv,
getPort,
getSiteURL
} from './config';
import { getNodeEnv, getPort, getSiteURL } from './config';
import { setup } from './utils/setup';
const main = async () => {
@@ -86,7 +80,7 @@ const main = async () => {
app.use(
cors({
credentials: true,
origin: await getSiteURL()
origin: await getSiteURL(),
})
);
@@ -126,7 +120,7 @@ const main = async () => {
app.use('/api/v1/stripe', v1StripeRouter);
app.use('/api/v1/integration', v1IntegrationRouter);
app.use('/api/v1/integration-auth', v1IntegrationAuthRouter);
app.use('/api/v1/folder', v1SecretsFolder)
app.use('/api/v1/folders', v1SecretsFolder);
// v2 routes (improvements)
app.use('/api/v2/signup', v2SignupRouter);
@@ -143,34 +137,37 @@ const main = async () => {
app.use('/api/v2/api-key', v2APIKeyDataRouter);
// v3 routes (experimental)
app.use("/api/v3/auth", v3AuthRouter);
app.use('/api/v3/secrets', v3SecretsRouter);
app.use('/api/v3/workspaces', v3WorkspacesRouter);
app.use("/api/v3/signup", v3SignupRouter);
// api docs
app.use('/api-docs', swaggerUi.serve, swaggerUi.setup(swaggerFile))
app.use('/api-docs', swaggerUi.serve, swaggerUi.setup(swaggerFile));
// server status
app.use('/api', healthCheck)
app.use('/api', healthCheck);
//* Handle unrouted requests and respond with proper error message as well as status code
app.use((req, res, next) => {
if (res.headersSent) return next();
next(RouteNotFoundError({ message: `The requested source '(${req.method})${req.url}' was not found` }))
next(
RouteNotFoundError({
message: `The requested source '(${req.method})${req.url}' was not found`,
})
);
});
app.use(requestErrorHandler);
const server = app.listen(await getPort(), async () => {
(await getLogger("backend-main")).info(
(await getLogger('backend-main')).info(
`Server started listening at port ${await getPort()}`
);
});
// await createTestUserForDevelopment();
setUpHealthEndpoint(server);
server.on("close", async () => {
server.on('close', async () => {
await DatabaseService.closeDatabase();
});
@@ -1,11 +1,12 @@
import { Types } from 'mongoose';
import { AuthData } from '../../middleware';
import { Types } from "mongoose";
import { AuthData } from "../../middleware";
export interface CreateSecretParams {
secretName: string;
workspaceId: Types.ObjectId;
environment: string;
type: 'shared' | 'personal';
folderId?: string;
type: "shared" | "personal";
authData: AuthData;
secretKeyCiphertext: string;
secretKeyIV: string;
@@ -28,7 +29,7 @@ export interface GetSecretParams {
secretName: string;
workspaceId: Types.ObjectId;
environment: string;
type?: 'shared' | 'personal';
type?: "shared" | "personal";
authData: AuthData;
}
@@ -36,17 +37,18 @@ export interface UpdateSecretParams {
secretName: string;
workspaceId: Types.ObjectId;
environment: string;
type: 'shared' | 'personal',
authData: AuthData
type: "shared" | "personal";
authData: AuthData;
secretValueCiphertext: string;
secretValueIV: string;
secretValueTag: string;
folderId?: string;
}
export interface DeleteSecretParams {
secretName: string;
workspaceId: Types.ObjectId;
environment: string;
type: 'shared' | 'personal';
type: "shared" | "personal";
authData: AuthData;
}
+30 -14
View File
@@ -1,29 +1,45 @@
import * as Sentry from '@sentry/node';
import { ErrorRequestHandler } from "express";
import { InternalServerError } from "../utils/errors";
import { getLogger } from "../utils/logger";
import RequestError, { LogLevel } from "../utils/requestError";
import { getNodeEnv } from '../config';
import { ErrorRequestHandler } from 'express';
import { InternalServerError } from '../utils/errors';
import { getLogger } from '../utils/logger';
import RequestError, { LogLevel } from '../utils/requestError';
export const requestErrorHandler: ErrorRequestHandler = async (error: RequestError | Error, req, res, next) => {
export const requestErrorHandler: ErrorRequestHandler = async (
error: RequestError | Error,
req,
res,
next
) => {
if (res.headersSent) return next();
//TODO: Find better way to type check for error. In current setting you need to cast type to get the functions and variables from RequestError
if (!(error instanceof RequestError)) {
error = InternalServerError({ context: { exception: error.message }, stack: error.stack });
(await getLogger('backend-main')).log((<RequestError>error).levelName.toLowerCase(), (<RequestError>error).message)
error = InternalServerError({
context: { exception: error.message },
stack: error.stack,
});
(await getLogger('backend-main')).log(
(<RequestError>error).levelName.toLowerCase(),
(<RequestError>error).message
);
}
//* Set Sentry user identification if req.user is populated
if (req.user !== undefined && req.user !== null) {
Sentry.setUser({ email: (req.user as any).email })
Sentry.setUser({ email: (req.user as any).email });
}
//* Only sent error to Sentry if LogLevel is one of the following level 'ERROR', 'EMERGENCY' or 'CRITICAL'
//* with this we will eliminate false-positive errors like 'BadRequestError', 'UnauthorizedRequestError' and so on
if ([LogLevel.ERROR, LogLevel.EMERGENCY, LogLevel.CRITICAL].includes((<RequestError>error).level)) {
Sentry.captureException(error)
if (
[LogLevel.ERROR, LogLevel.EMERGENCY, LogLevel.CRITICAL].includes(
(<RequestError>error).level
)
) {
Sentry.captureException(error);
}
res.status((<RequestError>error).statusCode).json((<RequestError>error).format(req))
next()
}
res
.status((<RequestError>error).statusCode)
.json((<RequestError>error).format(req));
next();
};
+41 -21
View File
@@ -1,36 +1,56 @@
import { Schema, Types, model } from 'mongoose';
import { Schema, model, Types } from "mongoose";
const folderSchema = new Schema({
name: {
type: String,
export type TFolderRootSchema = {
_id: Types.ObjectId;
workspace: Types.ObjectId;
environment: string;
nodes: TFolderSchema;
};
export type TFolderSchema = {
id: string;
name: string;
version: number;
children: TFolderSchema[];
};
const folderSchema = new Schema<TFolderSchema>({
id: {
required: true,
type: String,
},
version: {
required: true,
type: Number,
default: 1,
},
name: {
required: true,
type: String,
default: "root",
},
});
folderSchema.add({ children: [folderSchema] });
const folderRootSchema = new Schema<TFolderRootSchema>(
{
workspace: {
type: Schema.Types.ObjectId,
ref: 'Workspace',
ref: "Workspace",
required: true,
},
environment: {
type: String,
required: true,
},
parent: {
type: Schema.Types.ObjectId,
ref: 'Folder',
required: false, // optional for root folders
nodes: folderSchema,
},
path: {
type: String,
required: true
},
parentPath: {
type: String,
required: true,
},
}, {
timestamps: true
});
{
timestamps: true,
}
);
const Folder = model('Folder', folderSchema);
const Folder = model<TFolderRootSchema>("Folder", folderRootSchema);
export default Folder;
+39 -49
View File
@@ -1,12 +1,11 @@
import { Schema, model, Types } from 'mongoose';
import { Schema, model, Types } from "mongoose";
import {
SECRET_SHARED,
SECRET_PERSONAL,
ALGORITHM_AES_256_GCM,
ENCODING_SCHEME_UTF8,
ENCODING_SCHEME_BASE64
} from '../variables';
import { ROOT_FOLDER_PATH } from '../utils/folder';
ENCODING_SCHEME_BASE64,
} from "../variables";
export interface ISecret {
_id: Types.ObjectId;
@@ -28,11 +27,10 @@ export interface ISecret {
secretCommentIV?: string;
secretCommentTag?: string;
secretCommentHash?: string;
algorithm: 'aes-256-gcm';
keyEncoding: 'utf8' | 'base64';
algorithm: "aes-256-gcm";
keyEncoding: "utf8" | "base64";
tags?: string[];
path?: string;
folder?: Types.ObjectId;
folder?: string;
}
const secretSchema = new Schema<ISecret>(
@@ -40,115 +38,107 @@ const secretSchema = new Schema<ISecret>(
version: {
type: Number,
required: true,
default: 1
default: 1,
},
workspace: {
type: Schema.Types.ObjectId,
ref: 'Workspace',
required: true
ref: "Workspace",
required: true,
},
type: {
type: String,
enum: [SECRET_SHARED, SECRET_PERSONAL],
required: true
required: true,
},
user: {
// user associated with the personal secret
type: Schema.Types.ObjectId,
ref: 'User'
ref: "User",
},
tags: {
ref: 'Tag',
ref: "Tag",
type: [Schema.Types.ObjectId],
default: []
default: [],
},
environment: {
type: String,
required: true
required: true,
},
secretBlindIndex: {
type: String,
select: false
select: false,
},
secretKeyCiphertext: {
type: String,
required: true
required: true,
},
secretKeyIV: {
type: String, // symmetric
required: true
required: true,
},
secretKeyTag: {
type: String, // symmetric
required: true
required: true,
},
secretKeyHash: {
type: String
type: String,
},
secretValueCiphertext: {
type: String,
required: true
required: true,
},
secretValueIV: {
type: String, // symmetric
required: true
required: true,
},
secretValueTag: {
type: String, // symmetric
required: true
required: true,
},
secretValueHash: {
type: String
type: String,
},
secretCommentCiphertext: {
type: String,
required: false
required: false,
},
secretCommentIV: {
type: String, // symmetric
required: false
required: false,
},
secretCommentTag: {
type: String, // symmetric
required: false
required: false,
},
secretCommentHash: {
type: String,
required: false
required: false,
},
algorithm: { // the encryption algorithm used
algorithm: {
// the encryption algorithm used
type: String,
enum: [ALGORITHM_AES_256_GCM],
required: true
required: true,
default: ALGORITHM_AES_256_GCM,
},
keyEncoding: {
type: String,
enum: [
ENCODING_SCHEME_UTF8,
ENCODING_SCHEME_BASE64
],
required: true
},
// the full path to the secret in relation to folders
path: {
type: String,
required: false,
default: ROOT_FOLDER_PATH
enum: [ENCODING_SCHEME_UTF8, ENCODING_SCHEME_BASE64],
required: true,
default: ENCODING_SCHEME_UTF8,
},
folder: {
type: Schema.Types.ObjectId,
ref: 'Folder',
required: false,
type: String,
default: "root",
},
},
{
timestamps: true
timestamps: true,
}
);
secretSchema.index({ tags: 1 }, { background: true });
secretSchema.index({ tags: 1 }, { background: true })
const Secret = model<ISecret>('Secret', secretSchema);
const Secret = model<ISecret>("Secret", secretSchema);
export default Secret;
+42 -22
View File
@@ -1,50 +1,70 @@
import express, { Request, Response } from 'express';
import express from "express";
const router = express.Router();
import {
requireAuth,
requireWorkspaceAuth,
validateRequest
} from '../../middleware';
import { body, param } from 'express-validator';
import { createFolder, deleteFolder, getFolderById } from '../../controllers/v1/secretsFolderController';
import { ADMIN, MEMBER } from '../../variables';
validateRequest,
} from "../../middleware";
import { body, param, query } from "express-validator";
import {
createFolder,
deleteFolder,
getFolders,
updateFolderById,
} from "../../controllers/v1/secretsFolderController";
import { ADMIN, MEMBER } from "../../variables";
router.post(
'/',
"/",
requireAuth({
acceptedAuthModes: ['jwt']
acceptedAuthModes: ["jwt"],
}),
requireWorkspaceAuth({
acceptedRoles: [ADMIN, MEMBER],
locationWorkspaceId: 'body'
locationWorkspaceId: "body",
}),
body('workspaceId').exists(),
body('environment').exists(),
body('folderName').exists(),
body('parentFolderId'),
body("workspaceId").exists(),
body("environment").exists(),
body("folderName").exists(),
body("parentFolderId"),
validateRequest,
createFolder
);
router.delete(
'/:folderId',
router.patch(
"/:folderId",
requireAuth({
acceptedAuthModes: ['jwt']
acceptedAuthModes: ["jwt"],
}),
param('folderId').exists(),
body("workspaceId").exists(),
body("environment").exists(),
param("folderId").not().isIn(["root"]).exists(),
validateRequest,
updateFolderById
);
router.delete(
"/:folderId",
requireAuth({
acceptedAuthModes: ["jwt"],
}),
body("workspaceId").exists(),
body("environment").exists(),
param("folderId").not().isIn(["root"]).exists(),
validateRequest,
deleteFolder
);
router.get(
'/:folderId',
"/",
requireAuth({
acceptedAuthModes: ['jwt']
acceptedAuthModes: ["jwt"],
}),
param('folderId').exists(),
query("workspaceId").exists().isString().trim(),
query("environment").exists().isString().trim(),
query("parentFolderId").optional().isString().trim(),
validateRequest,
getFolderById
getFolders
);
export default router;
+63 -34
View File
@@ -5,7 +5,7 @@ import {
requireAuth,
requireWorkspaceAuth,
requireSecretsAuth,
validateRequest
validateRequest,
} from '../../middleware';
import { validateClientForSecrets } from '../../validation';
import { query, body } from 'express-validator';
@@ -20,22 +20,25 @@ import {
AUTH_MODE_JWT,
AUTH_MODE_SERVICE_ACCOUNT,
AUTH_MODE_SERVICE_TOKEN,
AUTH_MODE_API_KEY
AUTH_MODE_API_KEY,
} from '../../variables';
import {
BatchSecretRequest
} from '../../types/secret';
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(),
body('environment').exists().isString().trim(),
body('requests')
.exists()
@@ -43,13 +46,15 @@ router.post(
if (Array.isArray(requests)) {
const secretIds = requests
.map((request) => request.secret._id)
.filter((secretId) => secretId !== undefined)
.filter((secretId) => secretId !== undefined);
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: [],
});
}
}
@@ -63,24 +68,30 @@ router.post(
'/',
body('workspaceId').exists().isString().trim(),
body('environment').exists().isString().trim(),
body('folderId').default('root').isString().trim(),
body('secrets')
.exists()
.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 ||
(typeof secret.secretValueCiphertext !== 'string') ||
typeof secret.secretValueCiphertext !== 'string' ||
!secret.secretValueIV ||
!secret.secretValueTag
) {
throw new Error('secrets array must contain objects that have required secret properties');
throw new Error(
'secrets array must contain objects that have required secret properties'
);
}
}
} else if (typeof value === 'object') {
@@ -95,23 +106,29 @@ 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')
throw new Error('secrets must be an object or an array of objects');
}
return true;
}),
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
);
@@ -121,15 +138,21 @@ router.get(
query('workspaceId').exists().trim(),
query('environment').exists().trim(),
query('tagSlugs'),
query('folderId').default('root').isString().trim(),
validateRequest,
requireAuth({
acceptedAuthModes: [AUTH_MODE_JWT, AUTH_MODE_API_KEY, AUTH_MODE_SERVICE_TOKEN, AUTH_MODE_SERVICE_ACCOUNT]
acceptedAuthModes: [
AUTH_MODE_JWT,
AUTH_MODE_API_KEY,
AUTH_MODE_SERVICE_TOKEN,
AUTH_MODE_SERVICE_ACCOUNT,
],
}),
requireWorkspaceAuth({
acceptedRoles: [ADMIN, MEMBER],
locationWorkspaceId: 'query',
locationEnvironment: 'query',
requiredPermissions: [PERMISSION_READ_SECRETS]
requiredPermissions: [PERMISSION_READ_SECRETS],
}),
secretsController.getSecrets
);
@@ -141,34 +164,35 @@ 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
) {
if (!secret.id) {
throw new Error('Each secret must contain a ID property');
}
}
} else if (typeof value === 'object') {
// case: update 1 secret
if (
!value.id
) {
if (!value.id) {
throw new Error('secret must contain a ID property');
}
} 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');
}
return true;
}),
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
);
@@ -183,8 +207,9 @@ router.delete(
if (Array.isArray(value)) {
// case: delete multiple secrets
if (value.length === 0) throw new Error('secrets cannot be an empty array');
return value.every((id: string) => typeof id === 'string')
if (value.length === 0)
throw new Error('secrets cannot be an empty array');
return value.every((id: string) => typeof id === 'string');
}
throw new Error('secretIds must be a string or an array of strings');
@@ -193,11 +218,15 @@ 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
);
+189
View File
@@ -0,0 +1,189 @@
import { nanoid } from "nanoid";
import { TFolderSchema } from "../models/folder";
type TAppendFolderDTO = {
folderName: string;
parentFolderId?: string;
};
type TRenameFolderDTO = {
folderName: string;
folderId: string;
};
export const validateFolderName = (folderName: string) => {
const validNameRegex = /^[a-zA-Z0-9-_]+$/;
return validNameRegex.test(folderName);
};
export const generateFolderId = (): string => nanoid(12);
// simple bfs search
export const searchByFolderId = (
root: TFolderSchema,
folderId: string
): TFolderSchema | undefined => {
const queue = [root];
while (queue.length) {
const folder = queue.pop() as TFolderSchema;
if (folder.id === folderId) {
return folder;
}
queue.push(...folder.children);
}
};
export const folderBfsTraversal = async (
root: TFolderSchema,
callback: (data: TFolderSchema) => void | Promise<void>
) => {
const queue = [root];
while (queue.length) {
const folder = queue.pop() as TFolderSchema;
await callback(folder);
queue.push(...folder.children);
}
};
// bfs and then append to the folder
const appendChild = (folders: TFolderSchema, folderName: string) => {
const folder = folders.children.find(({ name }) => name === folderName);
if (folder) {
throw new Error("Folder already exists");
}
const id = generateFolderId();
folders.version += 1;
folders.children.push({
id,
name: folderName,
children: [],
version: 1,
});
return { id, name: folderName };
};
// root of append child wrapper
export const appendFolder = (
folders: TFolderSchema,
{ folderName, parentFolderId }: TAppendFolderDTO
) => {
const isRoot = !parentFolderId;
if (isRoot) {
return appendChild(folders, folderName);
}
const folder = searchByFolderId(folders, parentFolderId);
if (!folder) {
throw new Error("Parent Folder not found");
}
return appendChild(folder, folderName);
};
export const renameFolder = (
folders: TFolderSchema,
{ folderName, folderId }: TRenameFolderDTO
) => {
const folder = searchByFolderId(folders, folderId);
if (!folder) {
throw new Error("Folder doesn't exist");
}
folder.name = folderName;
};
// bfs but stops on parent folder
// Then unmount the required child and then return both
export const deleteFolderById = (folders: TFolderSchema, folderId: string) => {
const queue = [folders];
while (queue.length) {
const folder = queue.pop() as TFolderSchema;
const index = folder.children.findIndex(({ id }) => folderId === id);
if (index !== -1) {
const deletedFolder = folder.children.splice(index, 1);
return { deletedNode: deletedFolder[0], parent: folder };
}
queue.push(...folder.children);
}
};
// bfs but return parent of the folderID
export const getParentFromFolderId = (
folders: TFolderSchema,
folderId: string
) => {
const queue = [folders];
while (queue.length) {
const folder = queue.pop() as TFolderSchema;
const index = folder.children.findIndex(({ id }) => folderId === id);
if (index !== -1) return folder;
queue.push(...folder.children);
}
};
// to get all folders ids from everything from below nodes
export const getAllFolderIds = (folders: TFolderSchema) => {
const folderIds: Array<{ id: string; name: string }> = [];
const queue = [folders];
while (queue.length) {
const folder = queue.pop() as TFolderSchema;
folderIds.push({ id: folder.id, name: folder.name });
queue.push(...folder.children);
}
return folderIds;
};
// To get the path of a folder from the root. Used for breadcrumbs
// LOGIC: We do dfs instead if bfs
// Each time we go down we record the current node
// We then record the number of childs of each root node
// When we reach leaf node or when all childs of a root node are visited
// We remove it from path recorded by using the total child record
export const searchByFolderIdWithDir = (
folders: TFolderSchema,
folderId: string
) => {
const stack = [folders];
const dir: Array<{ id: string; name: string }> = [];
const hits: Record<string, number> = {};
while (stack.length) {
const folder = stack.shift() as TFolderSchema;
// score the hit
hits[folder.id] = folder.children.length;
const parent = dir[dir.length - 1];
if (parent) hits[parent.id] -= 1;
if (folder.id === folderId) {
dir.push({ name: folder.name, id: folder.id });
return { folder, dir };
}
if (folder.children.length) {
dir.push({ name: folder.name, id: folder.id });
stack.unshift(...folder.children);
} else {
if (!hits[parent.id]) {
dir.pop();
}
}
}
return;
};
// to get folder of a path given
// Like /frontend/folder#1
export const getFolderByPath = (folders: TFolderSchema, searchPath: string) => {
const path = searchPath.split("/").filter(Boolean);
const queue = [folders];
let segment: TFolderSchema | undefined;
while (queue.length && path.length) {
const folder = queue.pop();
const segmentPath = path.shift();
segment = folder?.children.find(({ name }) => name === segmentPath);
if (!segment) return;
queue.push(segment);
}
return segment;
};
+17 -24
View File
@@ -1,29 +1,37 @@
import { Types } from 'mongoose';
import { Assign, Omit } from 'utility-types';
import { ISecret } from '../../models';
import { mongo } from 'mongoose';
import { Types } from "mongoose";
import { Assign, Omit } from "utility-types";
import { ISecret } from "../../models";
import { mongo } from "mongoose";
// Everything is required, except the omitted types
export type CreateSecretRequestBody = Omit<ISecret, "user" | "version" | "environment" | "workspace">;
export type CreateSecretRequestBody = Omit<
ISecret,
"user" | "version" | "environment" | "workspace"
>;
// Omit the listed properties, then make everything optional and then make _id required
export type ModifySecretRequestBody = Assign<Partial<Omit<ISecret, "user" | "version" | "environment" | "workspace">>, { _id: string }>;
export type ModifySecretRequestBody = Assign<
Partial<Omit<ISecret, "user" | "version" | "environment" | "workspace">>,
{ _id: string }
>;
// Used for modeling sanitized secrets before uplaod. To be used for converting user input for uploading
export type SanitizedSecretModify = Partial<Omit<ISecret, "user" | "version" | "environment" | "workspace">>;
export type SanitizedSecretModify = Partial<
Omit<ISecret, "user" | "version" | "environment" | "workspace">
>;
// Everything is required, except the omitted types
export type SanitizedSecretForCreate = Omit<ISecret, "version" | "_id">;
export interface BatchSecretRequest {
id: string;
method: 'POST' | 'PATCH' | 'DELETE';
method: "POST" | "PATCH" | "DELETE";
secret: Secret;
}
export interface BatchSecret {
_id: string;
type: 'shared' | 'personal',
type: "shared" | "personal";
secretBlindIndex: string;
secretKeyCiphertext: string;
secretKeyIV: string;
@@ -36,18 +44,3 @@ export interface BatchSecret {
secretCommentTag: string;
tags: string[];
}
export interface BatchSecret {
_id: string;
type: 'shared' | 'personal',
secretKeyCiphertext: string;
secretKeyIV: string;
secretKeyTag: string;
secretValueCiphertext: string;
secretValueIV: string;
secretValueTag: string;
secretCommentCiphertext: string;
secretCommentIV: string;
secretCommentTag: string;
tags: string[];
}
+71 -71
View File
@@ -1,87 +1,87 @@
import Folder from "../models/folder";
// import Folder from "../models/folder";
export const ROOT_FOLDER_PATH = "/"
// export const ROOT_FOLDER_PATH = "/"
export const getFolderPath = async (folderId: string) => {
let currentFolder = await Folder.findById(folderId);
const pathSegments = [];
// export const getFolderPath = async (folderId: string) => {
// let currentFolder = await Folder.findById(folderId);
// const pathSegments = [];
while (currentFolder) {
pathSegments.unshift(currentFolder.name);
currentFolder = currentFolder.parent ? await Folder.findById(currentFolder.parent) : null;
}
// while (currentFolder) {
// pathSegments.unshift(currentFolder.name);
// currentFolder = currentFolder.parent ? await Folder.findById(currentFolder.parent) : null;
// }
return '/' + pathSegments.join('/');
};
// return '/' + pathSegments.join('/');
// };
/**
Returns the folder ID associated with the specified secret path in the given workspace and environment.
@param workspaceId - The ID of the workspace to search in.
@param environment - The environment to search in.
@param secretPath - The secret path to search for.
@returns The folder ID associated with the specified secret path, or undefined if the path is at the root folder level.
@throws Error if the specified secret path is not found.
*/
export const getFolderIdFromPath = async (workspaceId: string, environment: string, secretPath: string) => {
const secretPathParts = secretPath.split("/").filter(path => path != "")
if (secretPathParts.length <= 1) {
return undefined // root folder, so no folder id
}
// /**
// Returns the folder ID associated with the specified secret path in the given workspace and environment.
// @param workspaceId - The ID of the workspace to search in.
// @param environment - The environment to search in.
// @param secretPath - The secret path to search for.
// @returns The folder ID associated with the specified secret path, or undefined if the path is at the root folder level.
// @throws Error if the specified secret path is not found.
// */
// export const getFolderIdFromPath = async (workspaceId: string, environment: string, secretPath: string) => {
// const secretPathParts = secretPath.split("/").filter(path => path != "")
// if (secretPathParts.length <= 1) {
// return undefined // root folder, so no folder id
// }
const folderId = await Folder.find({ path: secretPath, workspace: workspaceId, environment: environment })
if (!folderId) {
throw Error("Secret path not found")
}
// const folderId = await Folder.find({ path: secretPath, workspace: workspaceId, environment: environment })
// if (!folderId) {
// throw Error("Secret path not found")
// }
return folderId
}
// return folderId
// }
/**
* Cleans up a path by removing empty parts, duplicate slashes,
* and ensuring it starts with ROOT_FOLDER_PATH.
* @param path - The input path to clean up.
* @returns The cleaned-up path string.
*/
export const normalizePath = (path: string) => {
if (path == undefined || path == "" || path == ROOT_FOLDER_PATH) {
return ROOT_FOLDER_PATH
}
// /**
// * Cleans up a path by removing empty parts, duplicate slashes,
// * and ensuring it starts with ROOT_FOLDER_PATH.
// * @param path - The input path to clean up.
// * @returns The cleaned-up path string.
// */
// export const normalizePath = (path: string) => {
// if (path == undefined || path == "" || path == ROOT_FOLDER_PATH) {
// return ROOT_FOLDER_PATH
// }
const pathParts = path.split("/").filter(part => part != "")
const cleanPathString = ROOT_FOLDER_PATH + pathParts.join("/")
// const pathParts = path.split("/").filter(part => part != "")
// const cleanPathString = ROOT_FOLDER_PATH + pathParts.join("/")
return cleanPathString
}
// return cleanPathString
// }
export const getFoldersInDirectory = async (workspaceId: string, environment: string, pathString: string) => {
const normalizedPath = normalizePath(pathString)
const foldersInDirectory = await Folder.find({
workspace: workspaceId,
environment: environment,
parentPath: normalizedPath,
});
// export const getFoldersInDirectory = async (workspaceId: string, environment: string, pathString: string) => {
// const normalizedPath = normalizePath(pathString)
// const foldersInDirectory = await Folder.find({
// workspace: workspaceId,
// environment: environment,
// parentPath: normalizedPath,
// });
return foldersInDirectory;
}
// return foldersInDirectory;
// }
/**
* Returns the parent path of the given path.
* @param path - The input path.
* @returns The parent path string.
*/
export const getParentPath = (path: string) => {
const normalizedPath = normalizePath(path);
const folderParts = normalizedPath.split('/').filter(part => part !== '');
// /**
// * Returns the parent path of the given path.
// * @param path - The input path.
// * @returns The parent path string.
// */
// export const getParentPath = (path: string) => {
// const normalizedPath = normalizePath(path);
// const folderParts = normalizedPath.split('/').filter(part => part !== '');
let folderParent = ROOT_FOLDER_PATH;
if (folderParts.length > 1) {
folderParent = ROOT_FOLDER_PATH + folderParts.slice(0, folderParts.length - 1).join('/');
}
// let folderParent = ROOT_FOLDER_PATH;
// if (folderParts.length > 1) {
// folderParent = ROOT_FOLDER_PATH + folderParts.slice(0, folderParts.length - 1).join('/');
// }
return folderParent;
}
// return folderParent;
// }
export const validateFolderName = (folderName: string) => {
const validNameRegex = /^[a-zA-Z0-9-_]+$/;
return validNameRegex.test(folderName);
}
// export const validateFolderName = (folderName: string) => {
// const validNameRegex = /^[a-zA-Z0-9-_]+$/;
// return validNameRegex.test(folderName);
// }
+69
View File
@@ -0,0 +1,69 @@
/*
Original work Copyright (c) 2016, Nikolay Nemshilov <nemshilov@gmail.com>
Modified work Copyright (c) 2016, David Banham <david@banham.id.au>
Permission to use, copy, modify, and/or distribute this software for any purpose
with or without fee is hereby granted, provided that the above copyright notice
and this permission notice appear in all copies.
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND
FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS
OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER
TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF
THIS SOFTWARE.
*/
/* eslint-disable @typescript-eslint/no-var-requires */
/* eslint-env node */
const Layer = require('express/lib/router/layer');
const Router = require('express/lib/router');
const last = (arr = []) => arr[arr.length - 1];
const noop = Function.prototype;
function copyFnProps(oldFn, newFn) {
Object.keys(oldFn).forEach((key) => {
newFn[key] = oldFn[key];
});
return newFn;
}
function wrap(fn) {
const newFn = function newFn(...args) {
const ret = fn.apply(this, args);
const next = (args.length === 5 ? args[2] : last(args)) || noop;
if (ret && ret.catch) ret.catch(err => next(err));
return ret;
};
Object.defineProperty(newFn, 'length', {
value: fn.length,
writable: false,
});
return copyFnProps(fn, newFn);
}
function patchRouterParam() {
const originalParam = Router.prototype.constructor.param;
Router.prototype.constructor.param = function param(name, fn) {
fn = wrap(fn);
return originalParam.call(this, name, fn);
};
}
Object.defineProperty(Layer.prototype, 'handle', {
enumerable: true,
get() {
return this.__handle;
},
set(fn) {
fn = wrap(fn);
this.__handle = fn;
},
});
module.exports = {
patchRouterParam
};
+125 -76
View File
@@ -1,7 +1,8 @@
import crypto from 'crypto';
import { encryptSymmetric128BitHexKeyUTF8 } from '../crypto';
import { EESecretService } from '../../ee/services';
import { SecretVersion } from '../../ee/models';
import crypto from "crypto";
import { Types } from "mongoose";
import { encryptSymmetric128BitHexKeyUTF8 } from "../crypto";
import { EESecretService } from "../../ee/services";
import { ISecretVersion, SecretSnapshot, SecretVersion } from "../../ee/models";
import {
Secret,
ISecret,
@@ -10,21 +11,15 @@ import {
Bot,
BackupPrivateKey,
IntegrationAuth,
} from '../../models';
import {
generateKeyPair
} from '../../utils/crypto';
import {
client,
getEncryptionKey,
getRootEncryptionKey
} from '../../config';
} from "../../models";
import { generateKeyPair } from "../../utils/crypto";
import { client, getEncryptionKey, getRootEncryptionKey } from "../../config";
import {
ALGORITHM_AES_256_GCM,
ENCODING_SCHEME_UTF8,
ENCODING_SCHEME_BASE64
} from '../../variables';
import { InternalServerError } from '../errors';
ENCODING_SCHEME_BASE64,
} from "../../variables";
import { InternalServerError } from "../errors";
/**
* Backfill secrets to ensure that they're all versioned and have
@@ -64,12 +59,12 @@ export const backfillSecretVersions = async () => {
workspace: s.workspace,
environment: s.environment,
algorithm: ALGORITHM_AES_256_GCM,
keyEncoding: ENCODING_SCHEME_UTF8
keyEncoding: ENCODING_SCHEME_UTF8,
})
),
});
}
}
};
/**
* Backfill workspace bots to ensure that every workspace has a bot
@@ -78,11 +73,11 @@ export const backfillBots = async () => {
const encryptionKey = await getEncryptionKey();
const rootEncryptionKey = await getRootEncryptionKey();
const workspaceIdsWithBot = await Bot.distinct('workspace');
const workspaceIdsToAddBot = await Workspace.distinct('_id', {
const workspaceIdsWithBot = await Bot.distinct("workspace");
const workspaceIdsToAddBot = await Workspace.distinct("_id", {
_id: {
$nin: workspaceIdsWithBot
}
$nin: workspaceIdsWithBot,
},
});
if (workspaceIdsToAddBot.length === 0) return;
@@ -95,11 +90,11 @@ export const backfillBots = async () => {
const {
ciphertext: encryptedPrivateKey,
iv,
tag
tag,
} = client.encryptSymmetric(privateKey, rootEncryptionKey);
return new Bot({
name: 'Infisical Bot',
name: "Infisical Bot",
workspace: workspaceToAddBot,
isActive: false,
publicKey,
@@ -107,20 +102,20 @@ export const backfillBots = async () => {
iv,
tag,
algorithm: ALGORITHM_AES_256_GCM,
keyEncoding: ENCODING_SCHEME_BASE64
keyEncoding: ENCODING_SCHEME_BASE64,
});
} else if (encryptionKey) {
const {
ciphertext: encryptedPrivateKey,
iv,
tag
tag,
} = encryptSymmetric128BitHexKeyUTF8({
plaintext: privateKey,
key: encryptionKey
key: encryptionKey,
});
return new Bot({
name: 'Infisical Bot',
name: "Infisical Bot",
workspace: workspaceToAddBot,
isActive: false,
publicKey,
@@ -128,47 +123,49 @@ export const backfillBots = async () => {
iv,
tag,
algorithm: ALGORITHM_AES_256_GCM,
keyEncoding: ENCODING_SCHEME_UTF8
keyEncoding: ENCODING_SCHEME_UTF8,
});
}
throw InternalServerError({
message: 'Failed to backfill workspace bots due to missing encryption key'
message:
"Failed to backfill workspace bots due to missing encryption key",
});
})
);
await Bot.insertMany(botsToInsert);
}
};
/**
* Backfill secret blind index data to ensure that every workspace
* has a secret blind index data
*/
export const backfillSecretBlindIndexData = async () => {
const encryptionKey = await getEncryptionKey();
const rootEncryptionKey = await getRootEncryptionKey();
const workspaceIdsBlindIndexed = await SecretBlindIndexData.distinct('workspace');
const workspaceIdsToBlindIndex = await Workspace.distinct('_id', {
const workspaceIdsBlindIndexed = await SecretBlindIndexData.distinct(
"workspace"
);
const workspaceIdsToBlindIndex = await Workspace.distinct("_id", {
_id: {
$nin: workspaceIdsBlindIndexed
}
$nin: workspaceIdsBlindIndexed,
},
});
if (workspaceIdsToBlindIndex.length === 0) return;
const secretBlindIndexDataToInsert = await Promise.all(
workspaceIdsToBlindIndex.map(async (workspaceToBlindIndex) => {
const salt = crypto.randomBytes(16).toString('base64');
const salt = crypto.randomBytes(16).toString("base64");
if (rootEncryptionKey) {
const {
ciphertext: encryptedSaltCiphertext,
iv: saltIV,
tag: saltTag
} = client.encryptSymmetric(salt, rootEncryptionKey)
tag: saltTag,
} = client.encryptSymmetric(salt, rootEncryptionKey);
return new SecretBlindIndexData({
workspace: workspaceToBlindIndex,
@@ -176,16 +173,16 @@ export const backfillSecretBlindIndexData = async () => {
saltIV,
saltTag,
algorithm: ALGORITHM_AES_256_GCM,
keyEncoding: ENCODING_SCHEME_BASE64
keyEncoding: ENCODING_SCHEME_BASE64,
});
} else if (encryptionKey) {
const {
ciphertext: encryptedSaltCiphertext,
iv: saltIV,
tag: saltTag
tag: saltTag,
} = encryptSymmetric128BitHexKeyUTF8({
plaintext: salt,
key: encryptionKey
key: encryptionKey,
});
return new SecretBlindIndexData({
@@ -194,18 +191,19 @@ export const backfillSecretBlindIndexData = async () => {
saltIV,
saltTag,
algorithm: ALGORITHM_AES_256_GCM,
keyEncoding: ENCODING_SCHEME_UTF8
keyEncoding: ENCODING_SCHEME_UTF8,
});
}
throw InternalServerError({
message: 'Failed to backfill secret blind index data due to missing encryption key'
message:
"Failed to backfill secret blind index data due to missing encryption key",
});
})
);
SecretBlindIndexData.insertMany(secretBlindIndexDataToInsert);
}
};
/**
* Backfill Secret, SecretVersion, SecretBlindIndexData, Bot,
@@ -213,22 +211,21 @@ export const backfillSecretBlindIndexData = async () => {
* they all have encryption metadata documented
*/
export const backfillEncryptionMetadata = async () => {
// backfill secret encryption metadata
await Secret.updateMany(
{
algorithm: {
$exists: false
$exists: false,
},
keyEncoding: {
$exists: false
}
$exists: false,
},
},
{
$set: {
algorithm: ALGORITHM_AES_256_GCM,
keyEncoding: ENCODING_SCHEME_UTF8
}
keyEncoding: ENCODING_SCHEME_UTF8,
},
}
);
@@ -236,17 +233,17 @@ export const backfillEncryptionMetadata = async () => {
await SecretVersion.updateMany(
{
algorithm: {
$exists: false
$exists: false,
},
keyEncoding: {
$exists: false
}
$exists: false,
},
},
{
$set: {
algorithm: ALGORITHM_AES_256_GCM,
keyEncoding: ENCODING_SCHEME_UTF8
}
keyEncoding: ENCODING_SCHEME_UTF8,
},
}
);
@@ -254,17 +251,17 @@ export const backfillEncryptionMetadata = async () => {
await SecretBlindIndexData.updateMany(
{
algorithm: {
$exists: false
$exists: false,
},
keyEncoding: {
$exists: false
}
$exists: false,
},
},
{
$set: {
algorithm: ALGORITHM_AES_256_GCM,
keyEncoding: ENCODING_SCHEME_UTF8
}
keyEncoding: ENCODING_SCHEME_UTF8,
},
}
);
@@ -272,17 +269,17 @@ export const backfillEncryptionMetadata = async () => {
await Bot.updateMany(
{
algorithm: {
$exists: false
$exists: false,
},
keyEncoding: {
$exists: false
}
$exists: false,
},
},
{
$set: {
algorithm: ALGORITHM_AES_256_GCM,
keyEncoding: ENCODING_SCHEME_UTF8
}
keyEncoding: ENCODING_SCHEME_UTF8,
},
}
);
@@ -290,17 +287,17 @@ export const backfillEncryptionMetadata = async () => {
await BackupPrivateKey.updateMany(
{
algorithm: {
$exists: false
$exists: false,
},
keyEncoding: {
$exists: false
}
$exists: false,
},
},
{
$set: {
algorithm: ALGORITHM_AES_256_GCM,
keyEncoding: ENCODING_SCHEME_UTF8
}
keyEncoding: ENCODING_SCHEME_UTF8,
},
}
);
@@ -308,17 +305,69 @@ export const backfillEncryptionMetadata = async () => {
await IntegrationAuth.updateMany(
{
algorithm: {
$exists: false
$exists: false,
},
keyEncoding: {
$exists: false
}
$exists: false,
},
},
{
$set: {
algorithm: ALGORITHM_AES_256_GCM,
keyEncoding: ENCODING_SCHEME_UTF8
}
keyEncoding: ENCODING_SCHEME_UTF8,
},
}
);
};
export const backfillSecretFolders = async () => {
await Secret.updateMany(
{
folder: {
$exists: false,
},
},
{
$set: {
folder: "root",
},
}
);
let secretSnapshots = await SecretSnapshot.find({
environment: {
$exists: false,
},
})
.populate<{ secretVersions: ISecretVersion[] }>("secretVersions")
.limit(50);
while (secretSnapshots.length > 0) {
for (const secSnapshot of secretSnapshots) {
const groupSnapByEnv: Record<string, Array<ISecretVersion>> = {};
secSnapshot.secretVersions.forEach((secVer) => {
if (!groupSnapByEnv?.[secVer.environment])
groupSnapByEnv[secVer.environment] = [];
groupSnapByEnv[secVer.environment].push(secVer);
});
const newSnapshots = Object.keys(groupSnapByEnv).map((snapEnv) => ({
...secSnapshot.toObject({ virtuals: false }),
_id: new Types.ObjectId(),
environment: snapEnv,
secretVersions: groupSnapByEnv[snapEnv],
}));
await SecretSnapshot.insertMany(newSnapshots);
await secSnapshot.delete();
}
secretSnapshots = await SecretSnapshot.find({
environment: {
$exists: false,
},
})
.populate<{ secretVersions: ISecretVersion[] }>("secretVersions")
.limit(50);
}
};
+22 -12
View File
@@ -3,26 +3,27 @@ import { DatabaseService, TelemetryService } from '../../services';
import { setTransporter } from '../../helpers/nodemailer';
import { EELicenseService } from '../../ee/services';
import { initSmtp } from '../../services/smtp';
import { createTestUserForDevelopment } from '../addDevelopmentUser'
import {
validateEncryptionKeysConfig
} from './validateConfig';
import { createTestUserForDevelopment } from '../addDevelopmentUser';
// eslint-disable-next-line @typescript-eslint/no-var-requires
const { patchRouterParam } = require('../patchAsyncRoutes');
import { validateEncryptionKeysConfig } from './validateConfig';
import {
backfillSecretVersions,
backfillBots,
backfillSecretBlindIndexData,
backfillEncryptionMetadata
backfillEncryptionMetadata,
backfillSecretFolders,
} from './backfillData';
import {
reencryptBotPrivateKeys,
reencryptSecretBlindIndexDataSalts
reencryptSecretBlindIndexDataSalts,
} from './reencryptData';
import {
getNodeEnv,
getMongoURL,
getSentryDSN,
getClientSecretGoogle,
getClientIdGoogle
getClientIdGoogle,
} from '../../config';
import { initializePassport } from '../auth';
@@ -37,6 +38,7 @@ import { initializePassport } from '../auth';
* - Re-encrypting data
*/
export const setup = async () => {
patchRouterParam();
await validateEncryptionKeysConfig();
await TelemetryService.logTelemetryMessage();
@@ -56,6 +58,14 @@ export const setup = async () => {
await initializePassport();
}
// re-encrypt any data previously encrypted under server hex 128-bit ENCRYPTION_KEY
// to base64 256-bit ROOT_ENCRYPTION_KEY
// await reencryptBotPrivateKeys();
// await reencryptSecretBlindIndexDataSalts();
// initializing the database connection
await DatabaseService.initDatabase(await getMongoURL());
/**
* NOTE: the order in this setup function is critical.
* It is important to backfill data before performing any re-encryption functionality.
@@ -66,20 +76,20 @@ export const setup = async () => {
await backfillBots();
await backfillSecretBlindIndexData();
await backfillEncryptionMetadata();
await backfillSecretFolders();
// re-encrypt any data previously encrypted under server hex 128-bit ENCRYPTION_KEY
// to base64 256-bit ROOT_ENCRYPTION_KEY
// await reencryptBotPrivateKeys();
// await reencryptSecretBlindIndexDataSalts();
await reencryptBotPrivateKeys();
await reencryptSecretBlindIndexDataSalts();
// initializing Sentry
Sentry.init({
dsn: await getSentryDSN(),
tracesSampleRate: 1.0,
debug: (await getNodeEnv()) === 'production' ? false : true,
environment: (await getNodeEnv())
environment: await getNodeEnv(),
});
await createTestUserForDevelopment();
}
};
@@ -7,6 +7,17 @@ import { useOrganization, useWorkspace } from '@app/context';
import { Select, SelectItem, Tooltip } from '../v2';
type Props = {
pageName: string;
isProjectRelated?: boolean;
isOrganizationRelated?: boolean;
currentEnv?: string;
userAvailableEnvs?: any[];
onEnvChange?: (slug: string) => void;
folders?: Array<{ id: string; name: string }>;
isFolderMode?: boolean;
};
// TODO: make links clickable and clean up
/**
@@ -29,15 +40,10 @@ export default function NavHeader({
isOrganizationRelated,
currentEnv,
userAvailableEnvs,
onEnvChange
}: {
pageName: string;
isProjectRelated?: boolean;
isOrganizationRelated?: boolean;
currentEnv?: string;
userAvailableEnvs?: any[];
onEnvChange?: (slug: string) => void;
}): JSX.Element {
onEnvChange,
folders,
isFolderMode
}: Props): JSX.Element {
const { currentWorkspace } = useWorkspace();
const { currentOrg } = useOrganization();
const router = useRouter();
@@ -95,6 +101,26 @@ export default function NavHeader({
</div>
</>
)}
{isFolderMode &&
folders?.map(({ id, name }, index) => {
const query = { ...router.query };
if (name !== 'root') query.folderId = id;
else delete query.folderId;
return (
<div className="flex items-center space-x-3" key={`breadcrumb-folder-${id}`}>
<FontAwesomeIcon icon={faAngleRight} className="ml-3 mr-1.5 text-xs text-gray-400" />
{index + 1 === folders?.length ? (
<span className="text-sm font-semibold text-bunker-300">{name}</span>
) : (
<Link passHref legacyBehavior href={{ pathname: '/dashboard/[id]', query }}>
<a className="text-sm font-semibold capitalize text-primary/80 hover:text-primary">
{name}
</a>
</Link>
)}
</div>
);
})}
</div>
);
}
@@ -1,4 +1,5 @@
import { ReactNode } from 'react';
import { SizeProp } from '@fortawesome/fontawesome-svg-core';
import { faCubesStacked, IconDefinition } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { twMerge } from 'tailwind-merge';
@@ -8,13 +9,25 @@ type Props = {
className?: string;
children?: ReactNode;
icon?: IconDefinition;
iconSize?: SizeProp;
};
export const EmptyState = ({ title, className, children, icon = faCubesStacked }: Props) => (
<div className={twMerge('flex w-full bg-bunker-700 flex-col items-center px-2 pt-6 text-bunker-300', className)}>
<FontAwesomeIcon icon={icon} size="2x" className='mr-4' />
<div className='flex flex-row items-center py-4'>
<div className="text-bunker-300 text-sm">{title}</div>
export const EmptyState = ({
title,
className,
children,
icon = faCubesStacked,
iconSize = '2x'
}: Props) => (
<div
className={twMerge(
'flex w-full flex-col items-center bg-bunker-700 px-2 pt-6 text-bunker-300',
className
)}
>
<FontAwesomeIcon icon={icon} size={iconSize} className="mr-4" />
<div className="flex flex-row items-center py-4">
<div className="text-sm text-bunker-300">{title}</div>
<div>{children}</div>
</div>
</div>
@@ -35,7 +35,7 @@ export const Tooltip = ({
sideOffset={5}
{...props}
className={twMerge(
`z-20 select-none max-w-[15rem] rounded-md bg-mineshaft-800 border border-mineshaft-600 py-2 px-4 font-light text-sm text-bunker-200 shadow-md
`z-50 max-w-[15rem] select-none rounded-md border border-mineshaft-600 bg-mineshaft-800 py-2 px-4 text-sm font-light text-bunker-200 shadow-md
data-[state=delayed-open]:data-[side=top]:animate-slideDownAndFade
data-[state=delayed-open]:data-[side=right]:animate-slideLeftAndFade
data-[state=delayed-open]:data-[side=left]:animate-slideRightAndFade
+1
View File
@@ -2,6 +2,7 @@ export * from './auth';
export * from './incidentContacts';
export * from './keys';
export * from './organization';
export * from './secretFolders';
export * from './secrets';
export * from './secretSnapshots';
export * from './serviceAccounts';
@@ -0,0 +1 @@
export { useCreateFolder, useDeleteFolder, useGetProjectFolders, useUpdateFolder } from './queries';
@@ -0,0 +1,129 @@
import { useCallback } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { apiRequest } from '@app/config/request';
import { secretSnapshotKeys } from '../secretSnapshots/queries';
import {
CreateFolderDTO,
DeleteFolderDTO,
GetProjectFoldersDTO,
TSecretFolder,
UpdateFolderDTO
} from './types';
const queryKeys = {
getSecretFolders: (workspaceId: string, environment: string, parentFolderId?: string) =>
['secret-folders', { workspaceId, environment, parentFolderId }] as const
};
export const useGetProjectFolders = ({
workspaceId,
parentFolderId,
environment,
isPaused,
sortDir
}: GetProjectFoldersDTO) =>
useQuery({
queryKey: queryKeys.getSecretFolders(workspaceId, environment, parentFolderId),
enabled: Boolean(workspaceId) && Boolean(environment) && !isPaused,
queryFn: async () => {
const { data } = await apiRequest.get<{ folders: TSecretFolder[]; dir: TSecretFolder[] }>(
'/api/v1/folders',
{
params: {
workspaceId,
environment,
parentFolderId
}
}
);
return data;
},
select: useCallback(
({ folders, dir }: { folders: TSecretFolder[]; dir: TSecretFolder[] }) => ({
dir,
folders: folders.sort((a, b) =>
sortDir === 'asc'
? a?.name?.localeCompare(b?.name || '')
: b?.name?.localeCompare(a?.name || '')
)
}),
[sortDir]
)
});
export const useCreateFolder = () => {
const queryClient = useQueryClient();
return useMutation<{}, {}, CreateFolderDTO>({
mutationFn: async (dto) => {
const { data } = await apiRequest.post('/api/v1/folders', dto);
return data;
},
onSuccess: (_, { workspaceId, environment, parentFolderId }) => {
queryClient.invalidateQueries(
queryKeys.getSecretFolders(workspaceId, environment, parentFolderId)
);
queryClient.invalidateQueries(
secretSnapshotKeys.count(workspaceId, environment, parentFolderId)
);
queryClient.invalidateQueries(
secretSnapshotKeys.list(workspaceId, environment, parentFolderId)
);
}
});
};
export const useUpdateFolder = (parentFolderId: string) => {
const queryClient = useQueryClient();
return useMutation<{}, {}, UpdateFolderDTO>({
mutationFn: async ({ folderId, name, environment, workspaceId }) => {
const { data } = await apiRequest.patch(`/api/v1/folders/${folderId}`, {
name,
environment,
workspaceId
});
return data;
},
onSuccess: (_, { workspaceId, environment }) => {
queryClient.invalidateQueries(
queryKeys.getSecretFolders(workspaceId, environment, parentFolderId)
);
queryClient.invalidateQueries(
secretSnapshotKeys.count(workspaceId, environment, parentFolderId)
);
queryClient.invalidateQueries(
secretSnapshotKeys.list(workspaceId, environment, parentFolderId)
);
}
});
};
export const useDeleteFolder = (parentFolderId: string) => {
const queryClient = useQueryClient();
return useMutation<{}, {}, DeleteFolderDTO>({
mutationFn: async ({ folderId, environment, workspaceId }) => {
const { data } = await apiRequest.delete(`/api/v1/folders/${folderId}`, {
data: {
environment,
workspaceId
}
});
return data;
},
onSuccess: (_, { workspaceId, environment }) => {
queryClient.invalidateQueries(
queryKeys.getSecretFolders(workspaceId, environment, parentFolderId)
);
queryClient.invalidateQueries(
secretSnapshotKeys.count(workspaceId, environment, parentFolderId)
);
queryClient.invalidateQueries(
secretSnapshotKeys.list(workspaceId, environment, parentFolderId)
);
}
});
};
@@ -0,0 +1,32 @@
export type TSecretFolder = {
id: string;
name: string;
};
export type GetProjectFoldersDTO = {
workspaceId: string;
environment: string;
parentFolderId?: string;
isPaused?: boolean;
sortDir?: 'asc' | 'desc';
};
export type CreateFolderDTO = {
workspaceId: string;
environment: string;
folderName: string;
parentFolderId?: string;
};
export type UpdateFolderDTO = {
workspaceId: string;
environment: string;
name: string;
folderId: string;
};
export type DeleteFolderDTO = {
workspaceId: string;
environment: string;
folderId: string;
};
@@ -17,18 +17,31 @@ import {
} from './types';
export const secretSnapshotKeys = {
list: (workspaceId: string) => [{ workspaceId }, 'secret-snapshot'] as const,
list: (workspaceId: string, env: string, folderId?: string) =>
[{ workspaceId, env, folderId }, 'secret-snapshot'] as const,
snapshotSecrets: (snapshotId: string) => [{ snapshotId }, 'secret-snapshot'] as const,
count: (workspaceId: string) => [{ workspaceId }, 'count', 'secret-snapshot']
count: (workspaceId: string, env: string, folderId?: string) => [
{ workspaceId, env, folderId },
'count',
'secret-snapshot'
]
};
const fetchWorkspaceSecretSnaphots = async (workspaceId: string, limit = 10, offset = 0) => {
const fetchWorkspaceSecretSnaphots = async (
workspaceId: string,
environment: string,
folderId?: string,
limit = 10,
offset = 0
) => {
const res = await apiRequest.get<{ secretSnapshots: TWorkspaceSecretSnapshot[] }>(
`/api/v1/workspace/${workspaceId}/secret-snapshots`,
{
params: {
limit,
offset
offset,
environment,
folderId
}
}
);
@@ -38,9 +51,16 @@ const fetchWorkspaceSecretSnaphots = async (workspaceId: string, limit = 10, off
export const useGetWorkspaceSecretSnapshots = (dto: GetWorkspaceSecretSnapshotsDTO) =>
useInfiniteQuery({
enabled: Boolean(dto.workspaceId),
queryKey: secretSnapshotKeys.list(dto.workspaceId),
queryFn: ({ pageParam }) => fetchWorkspaceSecretSnaphots(dto.workspaceId, dto.limit, pageParam),
enabled: Boolean(dto.workspaceId && dto.environment),
queryKey: secretSnapshotKeys.list(dto.workspaceId, dto.environment, dto?.folder),
queryFn: ({ pageParam }) =>
fetchWorkspaceSecretSnaphots(
dto.workspaceId,
dto.environment,
dto?.folder,
dto.limit,
pageParam
),
getNextPageParam: (lastPage, pages) =>
lastPage.length !== 0 ? pages.length * dto.limit : undefined
});
@@ -115,40 +135,54 @@ export const useGetSnapshotSecrets = ({ decryptFileKey, env, snapshotId }: TSnap
}
});
return { version: data.version, secrets: sharedSecrets, createdAt: data.createdAt };
return {
version: data.version,
secrets: sharedSecrets,
createdAt: data.createdAt,
folders: data.folderVersion
};
}
});
const fetchWorkspaceSecretSnaphotCount = async (workspaceId: string) => {
const fetchWorkspaceSecretSnaphotCount = async (
workspaceId: string,
environment: string,
folderId?: string
) => {
const res = await apiRequest.get<{ count: number }>(
`/api/v1/workspace/${workspaceId}/secret-snapshots/count`
`/api/v1/workspace/${workspaceId}/secret-snapshots/count`,
{
params: {
environment,
folderId
}
}
);
return res.data.count;
};
export const useGetWsSnapshotCount = (workspaceId: string) =>
export const useGetWsSnapshotCount = (workspaceId: string, env: string, folderId?: string) =>
useQuery({
enabled: Boolean(workspaceId),
queryKey: secretSnapshotKeys.count(workspaceId),
queryFn: () => fetchWorkspaceSecretSnaphotCount(workspaceId)
enabled: Boolean(workspaceId && env),
queryKey: secretSnapshotKeys.count(workspaceId, env, folderId),
queryFn: () => fetchWorkspaceSecretSnaphotCount(workspaceId, env, folderId)
});
export const usePerformSecretRollback = () => {
const queryClient = useQueryClient();
return useMutation<{}, {}, TSecretRollbackDTO>({
mutationFn: async (dto) => {
mutationFn: async ({ workspaceId, ...dto }) => {
const { data } = await apiRequest.post(
`/api/v1/workspace/${dto.workspaceId}/secret-snapshots/rollback`,
{
version: dto.version
}
`/api/v1/workspace/${workspaceId}/secret-snapshots/rollback`,
dto
);
return data;
},
onSuccess: (_, dto) => {
queryClient.invalidateQueries(secretSnapshotKeys.list(dto.workspaceId));
queryClient.invalidateQueries(secretSnapshotKeys.count(dto.workspaceId));
onSuccess: (_, { workspaceId, environment, folderId }) => {
queryClient.invalidateQueries([{ workspaceId, environment }, 'secrets']);
queryClient.invalidateQueries(secretSnapshotKeys.list(workspaceId, environment, folderId));
queryClient.invalidateQueries(secretSnapshotKeys.count(workspaceId, environment, folderId));
}
});
};
@@ -13,6 +13,7 @@ export type TWorkspaceSecretSnapshot = {
export type TSnapshotSecret = Omit<TWorkspaceSecretSnapshot, 'secretVersions'> & {
secretVersions: EncryptedSecretVersion[];
folderVersion: Array<{ name: string; id: string }>;
};
export type TSnapshotSecretProps = {
@@ -24,9 +25,13 @@ export type TSnapshotSecretProps = {
export type GetWorkspaceSecretSnapshotsDTO = {
workspaceId: string;
limit: number;
environment: string;
folder?: string;
};
export type TSecretRollbackDTO = {
workspaceId: string;
version: number;
environment: string;
folderId?: string;
};
+24 -11
View File
@@ -19,19 +19,24 @@ import {
export const secretKeys = {
// this is also used in secretSnapshot part
getProjectSecret: (workspaceId: string, env: string | string[]) => [
{ workspaceId, env },
getProjectSecret: (workspaceId: string, env: string | string[], folderId?: string) => [
{ workspaceId, env, folderId },
'secrets'
],
getSecretVersion: (secretId: string) => [{ secretId }, 'secret-versions']
};
const fetchProjectEncryptedSecrets = async (workspaceId: string, env: string | string[]) => {
const fetchProjectEncryptedSecrets = async (
workspaceId: string,
env: string | string[],
folderId?: string
) => {
if (typeof env === 'string') {
const { data } = await apiRequest.get<{ secrets: EncryptedSecret[] }>('/api/v2/secrets', {
params: {
environment: env,
workspaceId
workspaceId,
folderId: folderId || undefined
}
});
return data.secrets;
@@ -46,7 +51,8 @@ const fetchProjectEncryptedSecrets = async (workspaceId: string, env: string | s
const { data } = await apiRequest.get<{ secrets: EncryptedSecret[] }>('/api/v2/secrets', {
params: {
environment: envPoint,
workspaceId
workspaceId,
folderId
}
});
allEnvData = allEnvData.concat(data.secrets);
@@ -63,13 +69,14 @@ export const useGetProjectSecrets = ({
workspaceId,
env,
decryptFileKey,
isPaused
isPaused,
folderId
}: GetProjectSecretsDTO) =>
useQuery({
// wait for all values to be available
enabled: Boolean(decryptFileKey && workspaceId && env) && !isPaused,
queryKey: secretKeys.getProjectSecret(workspaceId, env),
queryFn: () => fetchProjectEncryptedSecrets(workspaceId, env),
queryKey: secretKeys.getProjectSecret(workspaceId, env, folderId),
queryFn: () => fetchProjectEncryptedSecrets(workspaceId, env, folderId),
select: (data) => {
const PRIVATE_KEY = localStorage.getItem('PRIVATE_KEY') as string;
const latestKey = decryptFileKey;
@@ -283,9 +290,15 @@ export const useBatchSecretsOp = () => {
return data;
},
onSuccess: (_, dto) => {
queryClient.invalidateQueries(secretKeys.getProjectSecret(dto.workspaceId, dto.environment));
queryClient.invalidateQueries(secretSnapshotKeys.list(dto.workspaceId));
queryClient.invalidateQueries(secretSnapshotKeys.count(dto.workspaceId));
queryClient.invalidateQueries(
secretKeys.getProjectSecret(dto.workspaceId, dto.environment, dto.folderId)
);
queryClient.invalidateQueries(
secretSnapshotKeys.list(dto.workspaceId, dto.environment, dto?.folderId)
);
queryClient.invalidateQueries(
secretSnapshotKeys.count(dto.workspaceId, dto.environment, dto?.folderId)
);
}
});
};
+3
View File
@@ -61,6 +61,7 @@ type SecretTagArg = { _id: string; name: string; slug: string };
export type UpdateSecretArg = {
_id: string;
folderId?: string;
type: 'shared' | 'personal';
secretName: string;
secretKeyCiphertext: string;
@@ -81,6 +82,7 @@ export type DeleteSecretArg = { _id: string };
export type BatchSecretDTO = {
workspaceId: string;
folderId: string;
environment: string;
requests: Array<
| { method: 'POST'; secret: CreateSecretArg }
@@ -93,6 +95,7 @@ export type GetProjectSecretsDTO = {
workspaceId: string;
env: string | string[];
decryptFileKey: UserWsKeyPair;
folderId?: string;
isPaused?: boolean;
onSuccess?: (data: DecryptedSecret[]) => void;
};
@@ -10,6 +10,7 @@ import {
faDownload,
faEye,
faEyeSlash,
faFolderPlus,
faMagnifyingGlass,
faPlus
} from '@fortawesome/free-solid-svg-icons';
@@ -21,6 +22,7 @@ import { useNotificationContext } from '@app/components/context/Notifications/No
import NavHeader from '@app/components/navigation/NavHeader';
import {
Button,
DeleteActionModal,
IconButton,
Input,
Modal,
@@ -37,7 +39,10 @@ import { useWorkspace } from '@app/context';
import { useLeaveConfirm, usePopUp, useToggle } from '@app/hooks';
import {
useBatchSecretsOp,
useCreateFolder,
useCreateWsTag,
useDeleteFolder,
useGetProjectFolders,
useGetProjectSecrets,
useGetSecretVersion,
useGetSnapshotSecrets,
@@ -48,13 +53,20 @@ import {
useGetWsSnapshotCount,
useGetWsTags,
usePerformSecretRollback,
useRegisterUserAction
useRegisterUserAction,
useUpdateFolder
} from '@app/hooks/api';
import { secretKeys } from '@app/hooks/api/secrets/queries';
import { WorkspaceEnv } from '@app/hooks/api/types';
import { CompareSecret } from './components/CompareSecret';
import { CreateTagModal } from './components/CreateTagModal';
import {
FolderForm,
FolderSection,
TDeleteFolderForm,
TEditFolderForm
} from './components/FolderSection';
import { PitDrawer } from './components/PitDrawer';
import { SecretDetailDrawer } from './components/SecretDetailDrawer';
import { SecretDropzone } from './components/SecretDropzone';
@@ -96,7 +108,9 @@ export const DashboardPage = ({ envFromTop }: { envFromTop: string }) => {
'addTag',
'secretSnapshots',
'uploadedSecOpts',
'compareSecrets'
'compareSecrets',
'folderForm',
'deleteFolder'
] as const);
const [isSecretValueHidden, setIsSecretValueHidden] = useToggle(true);
const [searchFilter, setSearchFilter] = useState('');
@@ -106,6 +120,9 @@ export const DashboardPage = ({ envFromTop }: { envFromTop: string }) => {
const deletedSecretIds = useRef<string[]>([]);
const { hasUnsavedChanges, setHasUnsavedChanges } = useLeaveConfirm({ initialValue: false });
const folderId = router.query.folderId as string;
const isRollbackMode = Boolean(snapshotId);
const { currentWorkspace, isLoading } = useWorkspace();
const workspaceId = currentWorkspace?._id as string;
const { data: latestFileKey } = useGetUserWsKey(workspaceId);
@@ -142,7 +159,16 @@ export const DashboardPage = ({ envFromTop }: { envFromTop: string }) => {
workspaceId,
env: selectedEnv?.slug || '',
decryptFileKey: latestFileKey!,
isPaused: Boolean(snapshotId)
isPaused: Boolean(snapshotId),
folderId
});
const { data: folderData, isLoading: isFoldersLoading } = useGetProjectFolders({
workspaceId: workspaceId || '',
environment: selectedEnv?.slug || '',
parentFolderId: folderId,
isPaused: isRollbackMode,
sortDir
});
const {
@@ -152,6 +178,8 @@ export const DashboardPage = ({ envFromTop }: { envFromTop: string }) => {
isFetchingNextPage
} = useGetWorkspaceSecretSnapshots({
workspaceId,
environment: selectedEnv?.slug || '',
folder: folderId,
limit: 10
});
@@ -165,8 +193,11 @@ export const DashboardPage = ({ envFromTop }: { envFromTop: string }) => {
decryptFileKey: latestFileKey!
});
const { data: snapshotCount, isLoading: isLoadingSnapshotCount } =
useGetWsSnapshotCount(workspaceId);
const { data: snapshotCount, isLoading: isLoadingSnapshotCount } = useGetWsSnapshotCount(
workspaceId,
selectedEnv?.slug || '',
folderId
);
const { data: wsTags } = useGetWsTags(workspaceId);
// mutation calls
@@ -174,6 +205,9 @@ export const DashboardPage = ({ envFromTop }: { envFromTop: string }) => {
const { mutateAsync: performSecretRollback } = usePerformSecretRollback();
const { mutateAsync: registerUserAction } = useRegisterUserAction();
const { mutateAsync: createWsTag } = useCreateWsTag();
const { mutateAsync: createFolder } = useCreateFolder();
const { mutateAsync: updateFolder } = useUpdateFolder(folderId);
const { mutateAsync: deleteFolder } = useDeleteFolder(folderId);
const method = useForm<FormData>({
// why any: well yup inferred ts expects other keys to defined as undefined
@@ -192,7 +226,6 @@ export const DashboardPage = ({ envFromTop }: { envFromTop: string }) => {
reset
} = method;
const { fields, prepend, append, remove } = useFieldArray({ control, name: 'secrets' });
const isRollbackMode = Boolean(snapshotId);
const isReadOnly = selectedEnv?.isWriteDenied;
const isAddOnly = selectedEnv?.isReadDenied && !selectedEnv?.isWriteDenied;
const canDoRollback = !isReadOnly && !isAddOnly;
@@ -281,7 +314,9 @@ export const DashboardPage = ({ envFromTop }: { envFromTop: string }) => {
try {
await performSecretRollback({
workspaceId,
version: snapshotSecret.version
version: snapshotSecret.version,
environment: selectedEnv?.slug || '',
folderId
});
setValue('isSnapshotMode', false);
setSnaphotId(null);
@@ -326,6 +361,7 @@ export const DashboardPage = ({ envFromTop }: { envFromTop: string }) => {
await batchSecretOp({
requests: batchedSecret,
workspaceId,
folderId,
environment: selectedEnv?.slug
});
createNotification({
@@ -356,9 +392,11 @@ export const DashboardPage = ({ envFromTop }: { envFromTop: string }) => {
}
const env = wsEnv?.find((el) => el.slug === slug);
if (env) setSelectedEnv(env);
const query: Record<string, string> = { ...router.query, env: slug };
delete query.folderId;
router.push({
pathname: router.pathname,
query: { ...router.query, env: slug }
query
});
};
@@ -393,6 +431,100 @@ export const DashboardPage = ({ envFromTop }: { envFromTop: string }) => {
}
};
const handleFolderOpen = (id: string) => {
setSearchFilter('');
router.push({
pathname: router.pathname,
query: {
...router.query,
folderId: id
}
});
};
const isEditFolder = Boolean(popUp?.folderForm?.data);
const handleFolderCreate = async (name: string) => {
try {
await createFolder({
workspaceId,
environment: selectedEnv?.slug || '',
folderName: name,
parentFolderId: folderId
});
createNotification({
type: 'success',
text: 'Successfully created folder'
});
handlePopUpClose('folderForm');
} catch (error) {
console.error(error);
createNotification({
text: 'Failed to create folder',
type: 'error'
});
}
};
const handleFolderUpdate = async (name: string) => {
const { id } = popUp?.folderForm?.data as TDeleteFolderForm;
try {
await updateFolder({
folderId: id,
workspaceId,
environment: selectedEnv?.slug || '',
name
});
createNotification({
type: 'success',
text: 'Successfully updated folder'
});
handlePopUpClose('folderForm');
} catch (error) {
console.error(error);
createNotification({
text: 'Failed to update folder',
type: 'error'
});
}
};
const handleFolderDelete = async () => {
const { id } = popUp?.deleteFolder?.data as TDeleteFolderForm;
try {
deleteFolder({
workspaceId,
environment: selectedEnv?.slug || '',
folderId: id
});
createNotification({
type: 'success',
text: 'Successfully removed folder'
});
handlePopUpClose('deleteFolder');
} catch (error) {
console.error(error);
createNotification({
text: 'Failed to remove folder',
type: 'error'
});
}
};
// when secrets is not loading and secrets list is empty
const isDashboardSecretEmpty = !isSecretsLoading && !fields?.length;
// folder list checks
const isFolderListLoading = isRollbackMode ? isSnapshotSecretsLoading : isFoldersLoading;
const folderList = isRollbackMode ? snapshotSecret?.folders : folderData?.folders;
// when using snapshot mode and snapshot is loading and snapshot list is empty
const isFoldersEmpty = !isFolderListLoading && !folderList?.length;
const isSnapshotSecretEmtpy =
isRollbackMode && !isSnapshotSecretsLoading && !snapshotSecret?.secrets?.length;
const isSecretEmpty = (!isRollbackMode && isDashboardSecretEmpty) || isSnapshotSecretEmtpy;
const isEmptyPage = isFoldersEmpty && isSecretEmpty;
if (isSecretsLoading || isEnvListLoading) {
return (
<div className="container mx-auto flex h-1/2 w-full items-center justify-center px-8 text-mineshaft-50 dark:[color-scheme:dark]">
@@ -401,64 +533,42 @@ export const DashboardPage = ({ envFromTop }: { envFromTop: string }) => {
);
}
// when secrets is not loading and secrets list is empty
const isDashboardSecretEmpty = !isSecretsLoading && false;
// when using snapshot mode and snapshot is loading and snapshot list is empty
const isSnapshotSecretEmtpy =
isRollbackMode && !isSnapshotSecretsLoading && !snapshotSecret?.secrets?.length;
const isSecretEmpty = (!isRollbackMode && isDashboardSecretEmpty) || isSnapshotSecretEmtpy;
const userAvailableEnvs = wsEnv?.filter(
({ isReadDenied, isWriteDenied }) => !isReadDenied || !isWriteDenied
);
return (
<div className="mr-auto container px-6 text-mineshaft-50 dark:[color-scheme:dark] h-full">
<div className="container mx-auto h-full px-6 text-mineshaft-50 dark:[color-scheme:dark]">
<FormProvider {...method}>
<form autoComplete="off">
<form autoComplete="off" className="h-full">
{/* breadcrumb row */}
<div className="relative right-6 mb-6 -top-2">
<div className="relative right-6 -top-2 mb-2">
<NavHeader
pageName={t('dashboard.title')}
currentEnv={
userAvailableEnvs?.filter((envir) => envir.slug === envFromTop)[0].name || ''
}
isFolderMode
folders={folderData?.dir}
isProjectRelated
userAvailableEnvs={userAvailableEnvs}
onEnvChange={onEnvChange}
/>
</div>
{/* This is only for rollbacks */}
{isRollbackMode &&
<div className="flex items-center justify-between">
<div className="flex items-center space-x-4">
<h1 className="text-3xl font-semibold">Secret Snapshot</h1>
<div className="mb-4">
<h6 className="text-2xl">{isRollbackMode ? 'Secret Snapshot' : ''}</h6>
{isRollbackMode && Boolean(snapshotSecret) && (
<Tag colorSchema="green">
{new Date(snapshotSecret?.createdAt || '').toLocaleString()}
</Tag>
)}
</div>
<div className="flex items-center space-x-2">
<Button
variant="star"
leftIcon={<FontAwesomeIcon icon={faArrowLeft} />}
onClick={() => {
setSnaphotId(null);
reset({ ...secrets, isSnapshotMode: false });
}}
className="h-10"
>
Go back
</Button>
</div>
</div>}
{/* Environment, search and other action row */}
<div className="mt-2 flex items-center space-x-2 justify-between">
<div className="flex-grow max-w-sm">
<div className="flex items-center justify-between space-x-2">
<div className="flex max-w-lg flex-grow space-x-2">
<Input
className="h-[2.3rem] bg-mineshaft-800 focus:bg-mineshaft-700/80 duration-200 placeholder-mineshaft-50"
placeholder="Search keys..."
className="h-[2.3rem] bg-mineshaft-800 placeholder-mineshaft-50 duration-200 focus:bg-mineshaft-700/80"
placeholder="Search by folder name, key name, comment..."
value={searchFilter}
onChange={(e) => setSearchFilter(e.target.value)}
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
@@ -500,8 +610,8 @@ export const DashboardPage = ({ envFromTop }: { envFromTop: string }) => {
</IconButton>
</Tooltip>
</div>
<div className='block xl:hidden'>
<Tooltip content='Point-in-time Recovery'>
<div className="block xl:hidden">
<Tooltip content="Point-in-time Recovery">
<IconButton
ariaLabel="recovery"
variant="outline_bg"
@@ -511,7 +621,7 @@ export const DashboardPage = ({ envFromTop }: { envFromTop: string }) => {
</IconButton>
</Tooltip>
</div>
<div className='hidden xl:block'>
<div className="hidden xl:block">
<Button
variant="outline_bg"
onClick={() => handlePopUpOpen('secretSnapshots')}
@@ -525,8 +635,20 @@ export const DashboardPage = ({ envFromTop }: { envFromTop: string }) => {
</div>
{!isReadOnly && !isRollbackMode && (
<>
<div className='block lg:hidden'>
<Tooltip content='Point-in-time Recovery'>
<div className="block lg:hidden">
<Tooltip content="Add Folder">
<IconButton
ariaLabel="recovery"
variant="outline_bg"
onClick={() => handlePopUpOpen('folderForm')}
>
<FontAwesomeIcon icon={faFolderPlus} />
</IconButton>
</Tooltip>
</div>
<div className="block lg:hidden">
<Tooltip content="Point-in-time Recovery">
<IconButton
ariaLabel="recovery"
variant="outline_bg"
@@ -544,7 +666,19 @@ export const DashboardPage = ({ envFromTop }: { envFromTop: string }) => {
</IconButton>
</Tooltip>
</div>
<div className='hidden lg:block'>
<div className="hidden lg:block">
<Button
leftIcon={<FontAwesomeIcon icon={faFolderPlus} />}
onClick={() => handlePopUpOpen('folderForm')}
isDisabled={isReadOnly || isRollbackMode}
variant="outline_bg"
className="h-10"
>
Add Folder
</Button>
</div>
<div className="hidden lg:block">
<Button
leftIcon={<FontAwesomeIcon icon={faPlus} />}
onClick={() => {
@@ -565,6 +699,19 @@ export const DashboardPage = ({ envFromTop }: { envFromTop: string }) => {
</div>
</>
)}
{isRollbackMode && (
<Button
variant="star"
leftIcon={<FontAwesomeIcon icon={faArrowLeft} />}
onClick={() => {
setSnaphotId(null);
reset({ ...secrets, isSnapshotMode: false });
}}
className="h-10"
>
Go back
</Button>
)}
<Button
isDisabled={isSubmitDisabled}
isLoading={isSubmitting}
@@ -580,15 +727,22 @@ export const DashboardPage = ({ envFromTop }: { envFromTop: string }) => {
</div>
<div
className={`${
isSecretEmpty ? 'flex flex-col items-center justify-center' : ''
} no-scrollbar::-webkit-scrollbar mt-3 h-[calc(100vh-220px)] overflow-x-hidden overflow-y-scroll no-scrollbar`}
isEmptyPage ? 'flex flex-col items-center justify-center' : ''
} no-scrollbar::-webkit-scrollbar mt-3 h-3/4 overflow-x-hidden overflow-y-scroll no-scrollbar`}
ref={secretContainer}
>
{!isSecretEmpty && (
<TableContainer className="max-h-[calc(100%-40px)] no-scrollbar no-scrollbar::-webkit-scrollbar">
{!isEmptyPage && (
<TableContainer className="no-scrollbar::-webkit-scrollbar max-h-[calc(100%-120px)] no-scrollbar">
<table className="secret-table relative">
<SecretTableHeader sortDir={sortDir} onSort={onSortSecrets} />
<tbody className="max-h-96 overflow-y-auto">
<FolderSection
onFolderOpen={handleFolderOpen}
onFolderUpdate={(id, name) => handlePopUpOpen('folderForm', { id, name })}
onFolderDelete={(id, name) => handlePopUpOpen('deleteFolder', { id, name })}
folders={folderList}
search={searchFilter}
/>
{fields.map(({ id, _id }, index) => (
<SecretInputRow
key={id}
@@ -609,7 +763,7 @@ export const DashboardPage = ({ envFromTop }: { envFromTop: string }) => {
<td colSpan={3} className="hover:bg-mineshaft-700">
<button
type="button"
className="pl-12 cursor-default w-full flex h-8 items-center justify-start font-normal text-bunker-300"
className="flex h-8 w-full cursor-default items-center justify-start pl-12 font-normal text-bunker-300"
onClick={onAppendSecret}
>
<FontAwesomeIcon icon={faPlus} />
@@ -643,7 +797,7 @@ export const DashboardPage = ({ envFromTop }: { envFromTop: string }) => {
onEnvCompare={(key) => handlePopUpOpen('compareSecrets', key)}
/>
<SecretDropzone
isSmaller={!isSecretEmpty}
isSmaller={!isEmptyPage}
onParsedEnv={handleUploadedEnv}
onAddNewSecret={onAppendSecret}
/>
@@ -695,6 +849,26 @@ export const DashboardPage = ({ envFromTop }: { envFromTop: string }) => {
</div>
</ModalContent>
</Modal>
<Modal
isOpen={popUp?.folderForm?.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle('folderForm', isOpen)}
>
<ModalContent title={isEditFolder ? 'Edit Folder' : 'Create Folder'}>
<FolderForm
isEdit={isEditFolder}
onUpdateFolder={handleFolderUpdate}
onCreateFolder={handleFolderCreate}
defaultFolderName={(popUp?.folderForm?.data as TEditFolderForm)?.name}
/>
</ModalContent>
</Modal>
<DeleteActionModal
isOpen={popUp.deleteFolder.isOpen}
deleteKey={(popUp.deleteFolder?.data as TDeleteFolderForm)?.name}
title="Do you want to delete this folder?"
onChange={(isOpen) => handlePopUpToggle('deleteFolder', isOpen)}
onDeleteApproved={handleFolderDelete}
/>
<Modal
isOpen={popUp?.compareSecrets?.isOpen}
onOpenChange={(open) => handlePopUpToggle('compareSecrets', open)}
@@ -0,0 +1,75 @@
import { Controller, useForm } from 'react-hook-form';
import { yupResolver } from '@hookform/resolvers/yup';
import * as yup from 'yup';
import { Button, FormControl, Input, ModalClose } from '@app/components/v2';
type Props = {
onCreateFolder: (folderName: string) => Promise<void>;
onUpdateFolder: (folderName: string) => Promise<void>;
isEdit?: boolean;
defaultFolderName?: string;
};
const formSchema = yup.object({
name: yup
.string()
.required()
.trim()
.matches(/^[a-zA-Z0-9-_]+$/, 'Folder name cannot contain spaces. Only underscore and dashes')
.label('Tag Name')
});
type TFormData = yup.InferType<typeof formSchema>;
export const FolderForm = ({
isEdit,
onCreateFolder,
defaultFolderName,
onUpdateFolder
}: Props): JSX.Element => {
const {
control,
reset,
formState: { isSubmitting },
handleSubmit
} = useForm<TFormData>({
resolver: yupResolver(formSchema),
defaultValues: {
name: defaultFolderName
}
});
const onSubmit = async ({ name }: TFormData) => {
if (isEdit) {
await onUpdateFolder(name);
} else {
await onCreateFolder(name);
}
reset();
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<Controller
control={control}
name="name"
defaultValue=""
render={({ field, fieldState: { error } }) => (
<FormControl label="Folder Name" isError={Boolean(error)} errorText={error?.message}>
<Input {...field} placeholder="Type your folder name" />
</FormControl>
)}
/>
<div className="mt-8 flex items-center">
<Button className="mr-4" type="submit" isDisabled={isSubmitting} isLoading={isSubmitting}>
{isEdit ? 'Save' : 'Create'}
</Button>
<ModalClose asChild>
<Button variant="plain" colorSchema="secondary">
Cancel
</Button>
</ModalClose>
</div>
</form>
);
};
@@ -0,0 +1,77 @@
import { faEdit, faFolder, faXmark } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { IconButton, Tooltip } from '@app/components/v2';
type Props = {
folders?: Array<{ id: string; name: string }>;
search?: string;
onFolderUpdate: (folderId: string, name: string) => void;
onFolderDelete: (folderId: string, name: string) => void;
onFolderOpen: (folderId: string) => void;
};
export const FolderSection = ({
onFolderUpdate: handleFolderUpdate,
onFolderDelete: handleFolderDelete,
onFolderOpen: handleFolderOpen,
search = '',
folders = []
}: Props) => {
return (
<>
{folders
.filter(({ name }) => name.toLowerCase().includes(search.toLowerCase()))
.map(({ id, name }) => (
<tr key={id} className="group flex flex-row items-center hover:bg-mineshaft-700 cursor-default">
<td className="flex h-10 w-10 items-center justify-center border-none px-4 ml-0.5">
<FontAwesomeIcon icon={faFolder} className="text-primary-700" />
</td>
<td
colSpan={2}
className="relative flex w-full min-w-[220px] items-center justify-between overflow-hidden text-ellipsis uppercase lg:min-w-[240px] xl:min-w-[280px]"
style={{ paddingTop: '0', paddingBottom: '0' }}
>
<div
className="flex-grow p-2 cursor-default"
onKeyDown={() => null}
tabIndex={0}
role="button"
onClick={() => handleFolderOpen(id)}
>
{name}
</div>
<div className="duration-0 flex h-10 w-16 items-center justify-end space-x-2.5 overflow-hidden border-l border-mineshaft-600 transition-all">
<div className="opacity-0 group-hover:opacity-100">
<Tooltip content="Settings" className="capitalize">
<IconButton
size="md"
colorSchema="primary"
variant="plain"
onClick={() => handleFolderUpdate(id, name)}
ariaLabel="expand"
>
<FontAwesomeIcon icon={faEdit} />
</IconButton>
</Tooltip>
</div>
<div className="opacity-0 group-hover:opacity-100">
<Tooltip content="Delete" className="capitalize">
<IconButton
size="md"
variant="plain"
colorSchema="danger"
ariaLabel="delete"
onClick={() => handleFolderDelete(id, name)}
>
<FontAwesomeIcon icon={faXmark} />
</IconButton>
</Tooltip>
</div>
</div>
</td>
</tr>
))}
</>
);
};
@@ -0,0 +1,3 @@
export { FolderForm } from './FolderForm';
export { FolderSection } from './FolderSection';
export * from './types';
@@ -0,0 +1,2 @@
export type TEditFolderForm = { id: string; name: string };
export type TDeleteFolderForm = { id: string; name: string };
@@ -24,9 +24,9 @@ export const MaskedInput = ({ isReadOnly, isSecretValueHidden, index, isOverridd
const syntaxHighlight = useCallback((val: string) => {
if (val?.length === 0) return <span className="font-sans text-bunker-400/80">EMPTY</span>;
return val?.split(REGEX).map((word) =>
return val?.split(REGEX).map((word, i) =>
word.match(REGEX) !== null ? (
<span className="ph-no-capture text-yellow" key={`${val}-${index + 1}`}>
<span className="ph-no-capture text-yellow" key={`${val}-${i + 1}`}>
{word.slice(0, 2)}
<span className="ph-no-capture text-yellow-200/80">{word.slice(2, word.length - 1)}</span>
{word.slice(word.length - 1, word.length) === '}' ? (
@@ -40,7 +40,7 @@ export const MaskedInput = ({ isReadOnly, isSecretValueHidden, index, isOverridd
)}
</span>
) : (
<span key={`${word}_${index + 1}`} className="ph-no-capture">
<span key={`${word}_${i + 1}`} className="ph-no-capture">
{word}
</span>
)
@@ -6,7 +6,6 @@ import {
faComment,
faEllipsis,
faInfoCircle,
faKey,
faPlus,
faTags,
faXmark
@@ -159,9 +158,8 @@ export const SecretInputRow = memo(
return (
<tr className="group flex flex-row items-center" key={index}>
<td className="flex h-10 w-10 items-center justify-center px-4 border-none">
{/* <div className="w-10 text-center text-xs text-bunker-400">{index + 1}</div> */}
<div className="w-10 text-center text-xs text-bunker-400"><FontAwesomeIcon icon={faKey} className="w-4 h-4 text-bunker-400/60 pl-2.5 pt-0.5" /></div>
<td className="flex h-10 w-10 items-center justify-center border-none px-4">
<div className="w-10 text-center text-xs text-bunker-400">{index + 1}</div>
</td>
<Controller
control={control}
@@ -200,7 +198,7 @@ export const SecretInputRow = memo(
</HoverCard>
)}
/>
<td className="flex h-10 border-none w-full flex-grow flex-row items-center justify-center border-r border-red">
<td className="flex h-10 w-full flex-grow flex-row items-center justify-center border-r border-none border-red">
<MaskedInput
isReadOnly={
isReadOnly || isRollbackMode || (isOverridden ? isAddOnly : shouldBeBlockedInAddOnly)
@@ -226,10 +224,10 @@ export const SecretInputRow = memo(
</Tag>
))}
{!(isReadOnly || isAddOnly || isRollbackMode) && (
<div className="overflow-hidden duration-0 ml-1">
<div className="duration-0 ml-1 overflow-hidden">
<Popover>
<PopoverTrigger asChild>
<div className="w-0 data-[state=open]:w-6 group-hover:w-6">
<div className="w-0 group-hover:w-6 data-[state=open]:w-6">
<Tooltip content="Add tags">
<IconButton
variant="plain"
@@ -291,7 +289,7 @@ export const SecretInputRow = memo(
</div>
)}
</div>
<div className="flex flex-row items-center h-full pr-2">
<div className="flex h-full flex-row items-center pr-2">
{!isAddOnly && (
<div>
<Tooltip content="Override with a personal value">
@@ -314,16 +312,14 @@ export const SecretInputRow = memo(
</div>
)}
<Tooltip content="Comment">
<div
className={`mt-0.5 overflow-hidden `}
>
<div className={`mt-0.5 overflow-hidden `}>
<Popover>
<PopoverTrigger asChild>
<IconButton
className={twMerge(
'overflow-hidden p-0 w-7',
'data-[state=open]:w-7 group-hover:w-7 w-0',
hasComment ? 'text-primary w-7' : 'group-hover:w-7'
'w-7 overflow-hidden p-0',
'w-0 group-hover:w-7 data-[state=open]:w-7',
hasComment ? 'w-7 text-primary' : 'group-hover:w-7'
)}
variant="plain"
size="md"
@@ -332,12 +328,13 @@ export const SecretInputRow = memo(
<FontAwesomeIcon icon={faComment} />
</IconButton>
</PopoverTrigger>
<PopoverContent className="w-auto border border-mineshaft-600 bg-mineshaft-800 p-2 drop-shadow-2xl" sticky="always">
<PopoverContent
className="w-auto border border-mineshaft-600 bg-mineshaft-800 p-2 drop-shadow-2xl"
sticky="always"
>
<FormControl label="Comment" className="mb-0">
<TextArea
isDisabled={
isReadOnly || isRollbackMode || shouldBeBlockedInAddOnly
}
isDisabled={isReadOnly || isRollbackMode || shouldBeBlockedInAddOnly}
className="border border-mineshaft-600 text-sm"
{...register(`secrets.${index}.comment`)}
rows={8}
@@ -349,7 +346,7 @@ export const SecretInputRow = memo(
</div>
</Tooltip>
</div>
<div className="duration-0 w-0 flex items-center justify-end space-x-2.5 overflow-hidden transition-all w-16 border-l border-mineshaft-600 h-10">
<div className="duration-0 flex h-10 w-16 items-center justify-end space-x-2.5 overflow-hidden border-l border-mineshaft-600 transition-all">
{!isAddOnly && (
<div className="opacity-0 group-hover:opacity-100">
<Tooltip content="Settings">
@@ -11,7 +11,7 @@ type Props = {
export const SecretTableHeader = ({ sortDir, onSort }: Props): JSX.Element => (
<thead className="sticky top-0 z-50 bg-mineshaft-800">
<tr className="top-0 flex flex-row">
<td className="flex w-10 items-center justify-center px-4 border-none">
<td className="flex w-10 items-center justify-center border-none px-4">
<div className="w-10 text-center text-xs text-transparent">{0}</div>
</td>
<td className="flex items-center">