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", "crypto-js": "^4.1.1",
"dotenv": "^16.0.1", "dotenv": "^16.0.1",
"express": "^4.18.1", "express": "^4.18.1",
"express-async-errors": "^3.1.1",
"express-rate-limit": "^6.7.0", "express-rate-limit": "^6.7.0",
"express-validator": "^6.14.2", "express-validator": "^6.14.2",
"handlebars": "^4.7.7", "handlebars": "^4.7.7",
@@ -42,6 +41,7 @@
"lodash": "^4.17.21", "lodash": "^4.17.21",
"mongoose": "^6.10.5", "mongoose": "^6.10.5",
"node-cache": "^5.1.2", "node-cache": "^5.1.2",
"nanoid": "^3.3.6",
"nodemailer": "^6.8.0", "nodemailer": "^6.8.0",
"passport": "^0.6.0", "passport": "^0.6.0",
"passport-google-oauth20": "^2.0.0", "passport-google-oauth20": "^2.0.0",
@@ -5190,14 +5190,6 @@
"node": ">= 0.10.0" "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": { "node_modules/express-rate-limit": {
"version": "6.7.0", "version": "6.7.0",
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-6.7.0.tgz", "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", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
"integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" "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": { "node_modules/natural-compare": {
"version": "1.4.0", "version": "1.4.0",
"resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", "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": { "express-rate-limit": {
"version": "6.7.0", "version": "6.7.0",
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-6.7.0.tgz", "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", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
"integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" "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": { "natural-compare": {
"version": "1.4.0", "version": "1.4.0",
"resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", "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", "crypto-js": "^4.1.1",
"dotenv": "^16.0.1", "dotenv": "^16.0.1",
"express": "^4.18.1", "express": "^4.18.1",
"express-async-errors": "^3.1.1",
"express-rate-limit": "^6.7.0", "express-rate-limit": "^6.7.0",
"express-validator": "^6.14.2", "express-validator": "^6.14.2",
"handlebars": "^4.7.7", "handlebars": "^4.7.7",
@@ -33,6 +32,7 @@
"lodash": "^4.17.21", "lodash": "^4.17.21",
"mongoose": "^6.10.5", "mongoose": "^6.10.5",
"node-cache": "^5.1.2", "node-cache": "^5.1.2",
"nanoid": "^3.3.6",
"nodemailer": "^6.8.0", "nodemailer": "^6.8.0",
"passport": "^0.6.0", "passport": "^0.6.0",
"passport-google-oauth20": "^2.0.0", "passport-google-oauth20": "^2.0.0",
@@ -1,107 +1,218 @@
import { Request, Response } from 'express'; import { Request, Response } from "express";
import { Secret } from '../../models'; import { Secret } from "../../models";
import Folder from '../../models/folder'; import Folder from "../../models/folder";
import { BadRequestError } from '../../utils/errors'; import { BadRequestError } from "../../utils/errors";
import { ROOT_FOLDER_PATH, getFolderPath, getParentPath, normalizePath, validateFolderName } from '../../utils/folder'; import {
import { ADMIN, MEMBER } from '../../variables'; appendFolder,
import { validateMembership } from '../../helpers/membership'; 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 // TODO
// verify workspace id/environment // verify workspace id/environment
export const createFolder = async (req: Request, res: Response) => { 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)) { 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 folders = await Folder.findOne({
const parentFolder = await Folder.find({ environment: environment, workspace: workspaceId, id: parentFolderId });
if (!parentFolder) {
throw BadRequestError({ message: "The parent folder doesn't exist" })
}
}
let completePath = await getFolderPath(parentFolderId)
if (completePath == ROOT_FOLDER_PATH) {
completePath = ""
}
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,
workspace: workspaceId, workspace: workspaceId,
environment: environment, environment,
parent: parentFolderId, }).lean();
path: normalizedCurrentPath // 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,
}); });
if (existingFolder) { return res.json({ folder });
return res.json(existingFolder) };
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" });
} }
const newFolder = new Folder({ // check that user is a member of the workspace
name: folderName, await validateMembership({
workspace: workspaceId, userId: req.user._id.toString(),
environment: environment, workspaceId,
parent: parentFolderId, acceptedRoles: [ADMIN, MEMBER],
path: normalizedCurrentPath,
parentPath: normalizedParentPath
}); });
await newFolder.save(); const parentFolder = getParentFromFolderId(folders.nodes, folderId);
if (!parentFolder) {
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" });
}
return res.json(newFolder) parentFolder.version += 1;
} folder.name = name;
await Folder.findByIdAndUpdate(folders._id, folders);
const folderVersion = new FolderVersion({
workspace: workspaceId,
environment,
nodes: parentFolder,
});
await folderVersion.save();
await EESecretService.takeSecretSnapshot({
workspaceId,
environment,
folderId: parentFolder.id,
});
return res.json({
message: "Successfully updated folder",
folder: { name: folder.name, id: folder.id },
});
};
export const deleteFolder = async (req: Request, res: Response) => { export const deleteFolder = async (req: Request, res: Response) => {
const { folderId } = req.params const { folderId } = req.params;
const queue: any[] = [folderId]; const { workspaceId, environment } = req.body;
const folder = await Folder.findById(folderId); const folders = await Folder.findOne({ workspace: workspaceId, environment });
if (!folder) { if (!folders) {
throw BadRequestError({ message: "The folder doesn't exist" }) throw BadRequestError({ message: "The folder doesn't exist" });
} }
// check that user is a member of the workspace // check that user is a member of the workspace
await validateMembership({ await validateMembership({
userId: req.user._id.toString(), userId: req.user._id.toString(),
workspaceId: folder.workspace as any, workspaceId,
acceptedRoles: [ADMIN, MEMBER] acceptedRoles: [ADMIN, MEMBER],
}); });
while (queue.length > 0) { const delOp = deleteFolderById(folders.nodes, folderId);
const currentFolderId = queue.shift(); if (!delOp) {
throw BadRequestError({ message: "The folder doesn't exist" });
}
const { deletedNode: delFolder, parent: parentFolder } = delOp;
const childFolders = await Folder.find({ parent: currentFolderId }); parentFolder.version += 1;
for (const childFolder of childFolders) { const delFolderIds = getAllFolderIds(delFolder);
queue.push(childFolder._id);
}
await Secret.deleteMany({ folder: currentFolderId }); await Folder.findByIdAndUpdate(folders._id, folders);
const folderVersion = new FolderVersion({
await Folder.deleteOne({ _id: currentFolderId }); workspace: workspaceId,
environment,
nodes: parentFolder,
});
await folderVersion.save();
if (delFolderIds.length) {
await Secret.deleteMany({
folder: { $in: delFolderIds.map(({ id }) => id) },
workspace: workspaceId,
environment,
});
} }
res.send() await EESecretService.takeSecretSnapshot({
} workspaceId,
environment,
folderId: parentFolder.id,
});
res.send({ message: "successfully deleted folders", folders: delFolderIds });
};
// TODO: validate workspace // TODO: validate workspace
export const getFolderById = async (req: Request, res: Response) => { export const getFolders = async (req: Request, res: Response) => {
const { folderId } = req.params const { workspaceId, environment, parentFolderId } = req.query as {
workspaceId: string;
environment: string;
parentFolderId?: string;
};
const folder = await Folder.findById(folderId); const folders = await Folder.findOne({ workspace: workspaceId, environment });
if (!folder) { if (!folders) {
throw BadRequestError({ message: "The folder doesn't exist" }) res.send({ folders: [], dir: [] });
return;
} }
// check that user is a member of the workspace // check that user is a member of the workspace
await validateMembership({ await validateMembership({
userId: req.user._id.toString(), userId: req.user._id.toString(),
workspaceId: folder.workspace as any, workspaceId,
acceptedRoles: [ADMIN, MEMBER] 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
+123 -117
View File
@@ -1,16 +1,16 @@
import { Request, Response } from 'express'; import { Request, Response } from "express";
import * as Sentry from '@sentry/node'; import * as Sentry from "@sentry/node";
import { Secret } from '../../../models'; import { Secret } from "../../../models";
import { SecretVersion } from '../../models'; import { SecretVersion } from "../../models";
import { EESecretService } from '../../services'; import { EESecretService } from "../../services";
/** /**
* Return secret versions for secret with id [secretId] * Return secret versions for secret with id [secretId]
* @param req * @param req
* @param res * @param res
*/ */
export const getSecretVersions = async (req: Request, res: Response) => { export const getSecretVersions = async (req: Request, res: Response) => {
/* /*
#swagger.summary = 'Return secret versions' #swagger.summary = 'Return secret versions'
#swagger.description = 'Return secret versions' #swagger.description = 'Return secret versions'
@@ -55,32 +55,34 @@ import { EESecretService } from '../../services';
} }
} }
*/ */
let secretVersions; let secretVersions;
try { try {
const { secretId } = req.params; const { secretId, workspaceId, environment, folderId } = req.params;
const offset: number = parseInt(req.query.offset as string); const offset: number = parseInt(req.query.offset as string);
const limit: number = parseInt(req.query.limit as string); const limit: number = parseInt(req.query.limit as string);
secretVersions = await SecretVersion.find({ secretVersions = await SecretVersion.find({
secret: secretId secret: secretId,
}) workspace: workspaceId,
.sort({ createdAt: -1 }) environment,
.skip(offset) folder: folderId,
.limit(limit); })
.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",
});
}
} catch (err) { return res.status(200).send({
Sentry.setUser({ email: req.user.email }); secretVersions,
Sentry.captureException(err); });
return res.status(400).send({ };
message: 'Failed to get secret versions'
});
}
return res.status(200).send({
secretVersions
});
}
/** /**
* Roll back secret with id [secretId] to version [version] * Roll back secret with id [secretId] to version [version]
@@ -89,7 +91,7 @@ import { EESecretService } from '../../services';
* @returns * @returns
*/ */
export const rollbackSecretVersion = async (req: Request, res: Response) => { export const rollbackSecretVersion = async (req: Request, res: Response) => {
/* /*
#swagger.summary = 'Roll back secret to a version.' #swagger.summary = 'Roll back secret to a version.'
#swagger.description = 'Roll back secret to a version.' #swagger.description = 'Roll back secret to a version.'
@@ -137,97 +139,101 @@ export const rollbackSecretVersion = async (req: Request, res: Response) => {
} }
} }
*/ */
let secret; let secret;
try { try {
const { secretId } = req.params; const { secretId } = req.params;
const { version } = req.body; const { version } = req.body;
// validate secret version // validate secret version
const oldSecretVersion = await SecretVersion.findOne({ const oldSecretVersion = await SecretVersion.findOne({
secret: secretId, secret: secretId,
version version,
}).select('+secretBlindIndex') }).select("+secretBlindIndex");
if (!oldSecretVersion) throw new Error('Failed to find secret version'); if (!oldSecretVersion) throw new Error("Failed to find secret version");
const { const {
workspace, workspace,
type, type,
user, user,
environment, environment,
secretBlindIndex, secretBlindIndex,
secretKeyCiphertext, secretKeyCiphertext,
secretKeyIV, secretKeyIV,
secretKeyTag, secretKeyTag,
secretValueCiphertext, secretValueCiphertext,
secretValueIV, secretValueIV,
secretValueTag, secretValueTag,
algorithm, folder,
keyEncoding algorithm,
} = oldSecretVersion; keyEncoding,
} = oldSecretVersion;
// update secret // update secret
secret = await Secret.findByIdAndUpdate( secret = await Secret.findByIdAndUpdate(
secretId, secretId,
{ {
$inc: { $inc: {
version: 1 version: 1,
}, },
workspace, workspace,
type, type,
user, user,
environment, environment,
...(secretBlindIndex ? { secretBlindIndex } : {}), ...(secretBlindIndex ? { secretBlindIndex } : {}),
secretKeyCiphertext, secretKeyCiphertext,
secretKeyIV, secretKeyIV,
secretKeyTag, secretKeyTag,
secretValueCiphertext, secretValueCiphertext,
secretValueIV, secretValueIV,
secretValueTag, secretValueTag,
algorithm, folderId: folder,
keyEncoding algorithm,
}, 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 // add new secret version
await new SecretVersion({ await new SecretVersion({
secret: secretId, secret: secretId,
version: secret.version, version: secret.version,
workspace, workspace,
type, type,
user, user,
environment, environment,
isDeleted: false, isDeleted: false,
...(secretBlindIndex ? { secretBlindIndex } : {}), ...(secretBlindIndex ? { secretBlindIndex } : {}),
secretKeyCiphertext, secretKeyCiphertext,
secretKeyIV, secretKeyIV,
secretKeyTag, secretKeyTag,
secretValueCiphertext, secretValueCiphertext,
secretValueIV, secretValueIV,
secretValueTag, secretValueTag,
algorithm, folder,
keyEncoding algorithm,
}).save(); keyEncoding,
}).save();
// take secret snapshot // take secret snapshot
await EESecretService.takeSecretSnapshot({ 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",
});
}
} catch (err) { return res.status(200).send({
Sentry.setUser({ email: req.user.email }); secret,
Sentry.captureException(err); });
return res.status(400).send({ };
message: 'Failed to roll back secret version'
});
}
return res.status(200).send({
secret
});
}
@@ -1,6 +1,10 @@
import { Request, Response } from 'express'; import { Request, Response } from "express";
import * as Sentry from '@sentry/node'; import * as Sentry from "@sentry/node";
import { SecretSnapshot } from '../../models'; import {
ISecretVersion,
SecretSnapshot,
TFolderRootVersionSchema,
} from "../../models";
/** /**
* Return secret snapshot with id [secretSnapshotId] * Return secret snapshot with id [secretSnapshotId]
@@ -9,25 +13,36 @@ import { SecretSnapshot } from '../../models';
* @returns * @returns
*/ */
export const getSecretSnapshot = async (req: Request, res: Response) => { export const getSecretSnapshot = async (req: Request, res: Response) => {
let secretSnapshot; let secretSnapshot;
try { try {
const { secretSnapshotId } = req.params; const { secretSnapshotId } = req.params;
secretSnapshot = await SecretSnapshot secretSnapshot = await SecretSnapshot.findById(secretSnapshotId)
.findById(secretSnapshotId) .lean()
.populate('secretVersions'); .populate<{ secretVersions: ISecretVersion[] }>("secretVersions")
.populate<{ folderVersion: TFolderRootVersionSchema }>("folderVersion");
if (!secretSnapshot) throw new Error('Failed to find secret snapshot'); if (!secretSnapshot) throw new Error("Failed to find secret snapshot");
} catch (err) {
} catch (err) { Sentry.setUser({ email: req.user.email });
Sentry.setUser({ email: req.user.email }); Sentry.captureException(err);
Sentry.captureException(err); return res.status(400).send({
return res.status(400).send({ message: "Failed to get secret snapshot",
message: 'Failed to get secret snapshot'
});
}
return res.status(200).send({
secretSnapshot
}); });
} }
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,
});
};
@@ -1,25 +1,30 @@
import { Request, Response } from 'express'; import { Request, Response } from "express";
import * as Sentry from '@sentry/node'; import * as Sentry from "@sentry/node";
import { Types } from 'mongoose'; import { PipelineStage, Types } from "mongoose";
import { Secret } from "../../../models";
import { import {
Secret SecretSnapshot,
} from '../../../models'; Log,
import { SecretVersion,
SecretSnapshot, ISecretVersion,
Log, FolderVersion,
SecretVersion, TFolderRootVersionSchema,
ISecretVersion } from "../../models";
} from '../../models'; import { EESecretService } from "../../services";
import { EESecretService } from '../../services'; import { getLatestSecretVersionIds } from "../../helpers/secretVersion";
import { getLatestSecretVersionIds } from '../../helpers/secretVersion'; import Folder, { TFolderSchema } from "../../../models/folder";
import { searchByFolderId } from "../../../services/FolderService";
/** /**
* Return secret snapshots for workspace with id [workspaceId] * Return secret snapshots for workspace with id [workspaceId]
* @param req * @param req
* @param res * @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.summary = 'Return project secret snapshot ids'
#swagger.description = 'Return project secret snapshots ids' #swagger.description = 'Return project secret snapshots ids'
@@ -64,57 +69,66 @@ import { getLatestSecretVersionIds } from '../../helpers/secretVersion';
} }
} }
*/ */
let secretSnapshots; let secretSnapshots;
try { try {
const { workspaceId } = req.params; const { workspaceId } = req.params;
const { environment, folderId } = req.query;
const offset: number = parseInt(req.query.offset as string); const offset: number = parseInt(req.query.offset as string);
const limit: number = parseInt(req.query.limit as string); const limit: number = parseInt(req.query.limit as string);
secretSnapshots = await SecretSnapshot.find({ secretSnapshots = await SecretSnapshot.find({
workspace: workspaceId workspace: workspaceId,
}) environment,
.sort({ createdAt: -1 }) folderId: folderId || "root",
.skip(offset) })
.limit(limit); .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",
});
}
} catch (err) { return res.status(200).send({
Sentry.setUser({ email: req.user.email }); secretSnapshots,
Sentry.captureException(err); });
return res.status(400).send({ };
message: 'Failed to get secret snapshots'
});
}
return res.status(200).send({
secretSnapshots
});
}
/** /**
* Return count of secret snapshots for workspace with id [workspaceId] * Return count of secret snapshots for workspace with id [workspaceId]
* @param req * @param req
* @param res * @param res
*/ */
export const getWorkspaceSecretSnapshotsCount = async (req: Request, res: Response) => { export const getWorkspaceSecretSnapshotsCount = async (
let count; req: Request,
try { res: Response
const { workspaceId } = req.params; ) => {
count = await SecretSnapshot.countDocuments({ let count;
workspace: workspaceId try {
}); const { workspaceId } = req.params;
} catch (err) { const { environment, folderId } = req.query;
Sentry.setUser({ email: req.user.email });
Sentry.captureException(err);
return res.status(400).send({
message: 'Failed to count number of secret snapshots'
});
}
return res.status(200).send({ count = await SecretSnapshot.countDocuments({
count 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",
});
}
return res.status(200).send({
count,
});
};
/** /**
* Rollback secret snapshot with id [secretSnapshotId] to version [version] * Rollback secret snapshot with id [secretSnapshotId] to version [version]
@@ -122,8 +136,11 @@ export const getWorkspaceSecretSnapshotsCount = async (req: Request, res: Respon
* @param res * @param res
* @returns * @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.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.' #swagger.description = 'Roll back project secrets to those captured in a secret snapshot version.'
@@ -174,158 +191,328 @@ export const rollbackWorkspaceSecretSnapshot = async (req: Request, res: Respons
} }
*/ */
let secrets; let secrets;
try { try {
const { workspaceId } = req.params; const { workspaceId } = req.params;
const { version } = req.body; const { version, environment, folderId = "root" } = req.body;
// validate secret snapshot // validate secret snapshot
const secretSnapshot = await SecretSnapshot.findOne({ const secretSnapshot = await SecretSnapshot.findOne({
workspace: workspaceId, workspace: workspaceId,
version version,
}).populate<{ secretVersions: ISecretVersion[]}>({ environment,
path: 'secretVersions', folderId: folderId,
select: '+secretBlindIndex' })
}); .populate<{ secretVersions: ISecretVersion[] }>({
path: "secretVersions",
select: "+secretBlindIndex",
})
.populate<{ folderVersion: TFolderRootVersionSchema }>("folderVersion");
if (!secretSnapshot) throw new Error('Failed to find secret snapshot'); if (!secretSnapshot) throw new Error("Failed to find secret snapshot");
// TODO: fix any const snapshotFolderTree = secretSnapshot.folderVersion;
const oldSecretVersionsObj: any = secretSnapshot.secretVersions const latestFolderTree = await Folder.findOne({
.reduce((accumulator, s) => ({ workspace: workspaceId,
...accumulator, environment,
[`${s.secret.toString()}`]: s });
}), {});
const latestSecretVersionIds = await getLatestSecretVersionIds({ const latestFolderVersion = await FolderVersion.findOne({
secretIds: secretSnapshot.secretVersions.map((sv) => sv.secret) environment,
}); workspace: workspaceId,
"nodes.id": folderId,
}).sort({ "nodes.version": -1 });
// TODO: fix any const oldSecretVersionsObj: Record<string, ISecretVersion> = {};
const latestSecretVersions: any = (await SecretVersion.find({ const secretIds: Types.ObjectId[] = [];
_id: { const folderIds: string[] = [folderId];
$in: latestSecretVersionIds.map((s) => s.versionId)
}
}, 'secret version'))
.reduce((accumulator, s) => ({
...accumulator,
[`${s.secret.toString()}`]: s
}), {});
// delete existing secrets secretSnapshot.secretVersions.forEach((snapSecVer) => {
await Secret.deleteMany({ oldSecretVersionsObj[snapSecVer.secret.toString()] = snapSecVer;
workspace: workspaceId secretIds.push(snapSecVer.secret);
}); });
// add secrets // the parent node from current latest one
secrets = await Secret.insertMany( // this will be modified according to the snapshot and latest snapshots
secretSnapshot.secretVersions.map((sv) => { const newFolderTree =
const secretId = sv.secret; latestFolderTree && searchByFolderId(latestFolderTree.nodes, folderId);
const {
workspace,
type,
user,
environment,
secretBlindIndex,
secretKeyCiphertext,
secretKeyIV,
secretKeyTag,
secretKeyHash,
secretValueCiphertext,
secretValueIV,
secretValueTag,
secretValueHash,
createdAt
} = oldSecretVersionsObj[secretId.toString()];
return ({ if (newFolderTree) {
_id: secretId, newFolderTree.children = snapshotFolderTree?.nodes?.children || [];
version: latestSecretVersions[secretId.toString()].version + 1, const queue = [newFolderTree];
workspace, // a bfs algorithm in which we take the latest snapshots of all the folders in a level
type, while (queue.length) {
user, const groupByFolderId: Record<string, TFolderSchema> = {};
environment, // the original queue is popped out completely to get what ever in a level
secretBlindIndex: secretBlindIndex ?? undefined, // subqueue is filled with all the children thus next level folders
secretKeyCiphertext, // subQueue will then be transfered to the oriinal queue
secretKeyIV, const subQueue: TFolderSchema[] = [];
secretKeyTag, // get everything inside a level
secretKeyHash, while (queue.length) {
secretValueCiphertext, const folder = queue.pop() as TFolderSchema;
secretValueIV, folder.children.forEach((el) => {
secretValueTag, folderIds.push(el.id); // push ids and data into queu
secretValueHash, subQueue.push(el);
secretCommentCiphertext: '', // to modify the original tree very fast we keep a reference object
secretCommentIV: '', // key with folder id and pointing to the various nodes
secretCommentTag: '', groupByFolderId[el.id] = el;
createdAt });
}); }
}) // 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,
]);
// add secret versions // recursive snapshotting each level
const secretV = await SecretVersion.insertMany( latestSnapshotsByFolders.forEach((snap) => {
secrets.map(({ // mutate the folder tree to update the nodes to the latest version tree
_id, // we are reconstructing the folder tree by latest snapshots here
version, if (groupByFolderId[snap.doc.folderId]) {
workspace, groupByFolderId[snap.doc.folderId].children =
type, snap.doc?.folderVersion?.nodes?.children || [];
user, }
environment,
secretBlindIndex,
secretKeyCiphertext,
secretKeyIV,
secretKeyTag,
secretKeyHash,
secretValueCiphertext,
secretValueIV,
secretValueTag,
secretValueHash
}) => ({
_id: new Types.ObjectId(),
secret: _id,
version,
workspace,
type,
user,
environment,
isDeleted: false,
secretBlindIndex: secretBlindIndex ?? undefined,
secretKeyCiphertext,
secretKeyIV,
secretKeyTag,
secretKeyHash,
secretValueCiphertext,
secretValueIV,
secretValueTag,
secretValueHash
}))
);
// update secret versions of restored secrets as not deleted // push all children of next level snapshots
await SecretVersion.updateMany({ if (snap.doc.folderVersion?.nodes?.children) {
secret: { queue.push(...snap.doc.folderVersion.nodes.children);
$in: secretSnapshot.secretVersions.map((sv) => sv.secret) }
}
}, {
isDeleted: false
});
// take secret snapshot snap.doc.secretVersions.forEach((snapSecVer) => {
await EESecretService.takeSecretSnapshot({ // record all the secrets
workspaceId: new Types.ObjectId(workspaceId) oldSecretVersionsObj[snapSecVer.secret.toString()] = snapSecVer;
}); secretIds.push(snapSecVer.secret);
} catch (err) { });
Sentry.setUser({ email: req.user.email }); });
Sentry.captureException(err);
return res.status(400).send({ queue.push(...subQueue);
message: 'Failed to roll back secret snapshot' }
});
} }
return res.status(200).send({ // TODO: fix any
secrets 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,
}),
{}
);
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(secDelQuery);
await Folder.deleteOne({
workspace: workspaceId,
environment,
});
// add secrets
secrets = await Secret.insertMany(
Object.keys(oldSecretVersionsObj).map((sv) => {
const {
secret: secretId,
workspace,
type,
user,
environment,
secretBlindIndex,
secretKeyCiphertext,
secretKeyIV,
secretKeyTag,
secretValueCiphertext,
secretValueIV,
secretValueTag,
createdAt,
algorithm,
keyEncoding,
folder: secFolderId,
} = oldSecretVersionsObj[sv];
return {
_id: secretId,
version: latestSecretVersions[secretId.toString()].version + 1,
workspace,
type,
user,
environment,
secretBlindIndex: secretBlindIndex ?? undefined,
secretKeyCiphertext,
secretKeyIV,
secretKeyTag,
secretValueCiphertext,
secretValueIV,
secretValueTag,
secretCommentCiphertext: "",
secretCommentIV: "",
secretCommentTag: "",
createdAt,
algorithm,
keyEncoding,
folder: secFolderId,
};
})
);
// add secret versions
const secretV = await SecretVersion.insertMany(
secrets.map(
({
_id,
version,
workspace,
type,
user,
environment,
secretBlindIndex,
secretKeyCiphertext,
secretKeyIV,
secretKeyTag,
secretValueCiphertext,
secretValueIV,
secretValueTag,
algorithm,
keyEncoding,
folder: secFolderId,
}) => ({
_id: new Types.ObjectId(),
secret: _id,
version,
workspace,
type,
user,
environment,
isDeleted: false,
secretBlindIndex: secretBlindIndex ?? undefined,
secretKeyCiphertext,
secretKeyIV,
secretKeyTag,
secretValueCiphertext,
secretValueIV,
secretValueTag,
algorithm,
keyEncoding,
folder: secFolderId,
})
)
);
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),
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",
});
}
return res.status(200).send({
secrets,
});
};
/** /**
* Return (audit) logs for workspace with id [workspaceId] * Return (audit) logs for workspace with id [workspaceId]
@@ -334,7 +521,7 @@ export const rollbackWorkspaceSecretSnapshot = async (req: Request, res: Respons
* @returns * @returns
*/ */
export const getWorkspaceLogs = async (req: Request, res: Response) => { export const getWorkspaceLogs = async (req: Request, res: Response) => {
/* /*
#swagger.summary = 'Return project (audit) logs' #swagger.summary = 'Return project (audit) logs'
#swagger.description = 'Return project (audit) logs' #swagger.description = 'Return project (audit) logs'
@@ -400,43 +587,41 @@ export const getWorkspaceLogs = async (req: Request, res: Response) => {
} }
} }
*/ */
let logs let logs;
try { try {
const { workspaceId } = req.params; const { workspaceId } = req.params;
const offset: number = parseInt(req.query.offset as string); const offset: number = parseInt(req.query.offset as string);
const limit: number = parseInt(req.query.limit as string); const limit: number = parseInt(req.query.limit as string);
const sortBy: string = req.query.sortBy as string; const sortBy: string = req.query.sortBy as string;
const userId: string = req.query.userId as string; const userId: string = req.query.userId as string;
const actionNames: string = req.query.actionNames as string; const actionNames: string = req.query.actionNames as string;
logs = await Log.find({ logs = await Log.find({
workspace: workspaceId, workspace: workspaceId,
...( userId ? { user: userId } : {}), ...(userId ? { user: userId } : {}),
...( ...(actionNames
actionNames ? {
? { actionNames: {
actionNames: { $in: actionNames.split(","),
$in: actionNames.split(',') },
} }
} : {} : {}),
) })
}) .sort({ createdAt: sortBy === "recent" ? -1 : 1 })
.sort({ createdAt: sortBy === 'recent' ? -1 : 1 }) .skip(offset)
.skip(offset) .limit(limit)
.limit(limit) .populate("actions")
.populate('actions') .populate("user serviceAccount serviceTokenData");
.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",
});
}
} catch (err) { return res.status(200).send({
Sentry.setUser({ email: req.user.email }); logs,
Sentry.captureException(err); });
return res.status(400).send({ };
message: 'Failed to get workspace logs'
});
}
return res.status(200).send({
logs
});
}
+25 -3
View File
@@ -1,6 +1,11 @@
import { Types } from "mongoose"; import { Types } from "mongoose";
import { Secret, ISecret } from "../../models"; 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 * 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 ({ const takeSecretSnapshotHelper = async ({
workspaceId, workspaceId,
environment,
folderId = "root",
}: { }: {
workspaceId: Types.ObjectId; workspaceId: Types.ObjectId;
environment: string;
folderId?: string;
}) => { }) => {
// get all folder ids
const secretIds = ( const secretIds = (
await Secret.find( await Secret.find(
{ {
workspace: workspaceId, workspace: workspaceId,
environment,
folder: folderId,
}, },
"_id" "_id"
) ).lean()
).map((s) => s._id); ).map((s) => s._id);
const latestSecretVersions = ( const latestSecretVersions = (
await SecretVersion.aggregate([ await SecretVersion.aggregate([
{ {
$match: { $match: {
environment,
workspace: new Types.ObjectId(workspaceId),
secret: { secret: {
$in: secretIds, $in: secretIds,
}, },
@@ -45,6 +59,11 @@ const takeSecretSnapshotHelper = async ({
}, },
]).exec() ]).exec()
).map((s) => s.versionId); ).map((s) => s.versionId);
const latestFolderVersion = await FolderVersion.findOne({
environment,
workspace: workspaceId,
"nodes.id": folderId,
}).sort({ "nodes.version": -1 });
const latestSecretSnapshot = await SecretSnapshot.findOne({ const latestSecretSnapshot = await SecretSnapshot.findOne({
workspace: workspaceId, workspace: workspaceId,
@@ -52,8 +71,11 @@ const takeSecretSnapshotHelper = async ({
const secretSnapshot = await new SecretSnapshot({ const secretSnapshot = await new SecretSnapshot({
workspace: workspaceId, workspace: workspaceId,
environment,
version: latestSecretSnapshot ? latestSecretSnapshot.version + 1 : 1, version: latestSecretSnapshot ? latestSecretSnapshot.version + 1 : 1,
secretVersions: latestSecretVersions, secretVersions: latestSecretVersions,
folderId,
folderVersion: latestFolderVersion,
}).save(); }).save();
return secretSnapshot; return secretSnapshot;
@@ -96,5 +118,5 @@ const markDeletedSecretVersionsHelper = async ({
export { export {
takeSecretSnapshotHelper, takeSecretSnapshotHelper,
addSecretVersionsHelper, addSecretVersionsHelper,
markDeletedSecretVersionsHelper markDeletedSecretVersionsHelper,
}; };
+56 -66
View File
@@ -1,5 +1,5 @@
import { Types } from 'mongoose'; import { Types } from "mongoose";
import { SecretVersion } from '../models'; import { SecretVersion } from "../models";
/** /**
* Return latest secret versions for secrets with ids [secretIds] * Return latest secret versions for secrets with ids [secretIds]
@@ -8,39 +8,32 @@ import { SecretVersion } from '../models';
* @returns * @returns
*/ */
const getLatestSecretVersionIds = async ({ const getLatestSecretVersionIds = async ({
secretIds secretIds,
}: { }: {
secretIds: Types.ObjectId[]; secretIds: Types.ObjectId[];
}) => { }) => {
interface LatestSecretVersionId { const latestSecretVersionIds = await SecretVersion.aggregate([
_id: Types.ObjectId; {
version: number; $match: {
versionId: Types.ObjectId; secret: {
} $in: secretIds,
const latestSecretVersionIds = (await SecretVersion.aggregate([
{
$match: {
secret: {
$in: secretIds
}
}
}, },
{ },
$group: { },
_id: '$secret', {
version: { $max: '$version' }, $group: {
versionId: { $max: '$_id' } // id of latest secret version _id: "$secret",
} version: { $max: "$version" },
}, versionId: { $max: "$_id" }, // id of latest secret version
{ },
$sort: { version: -1 } },
} {
]) $sort: { version: -1 },
.exec()); },
]).exec();
return latestSecretVersionIds; return latestSecretVersionIds;
} };
/** /**
* Return latest [n] secret versions for secrets with ids [secretIds] * Return latest [n] secret versions for secrets with ids [secretIds]
@@ -50,43 +43,40 @@ const getLatestSecretVersionIds = async ({
* @returns * @returns
*/ */
const getLatestNSecretSecretVersionIds = async ({ const getLatestNSecretSecretVersionIds = async ({
secretIds, secretIds,
n n,
}: { }: {
secretIds: Types.ObjectId[]; secretIds: Types.ObjectId[];
n: number; n: number;
}) => { }) => {
// TODO: optimize query // TODO: optimize query
const latestNSecretVersions = (await SecretVersion.aggregate([ const latestNSecretVersions = await SecretVersion.aggregate([
{ {
$match: { $match: {
secret: { secret: {
$in: secretIds, $in: secretIds,
},
},
}, },
{ },
$sort: { version: -1 }, },
}, {
{ $sort: { version: -1 },
$group: { },
_id: "$secret", {
versions: { $push: "$$ROOT" }, $group: {
}, _id: "$secret",
}, versions: { $push: "$$ROOT" },
{ },
$project: { },
_id: 0, {
secret: "$_id", $project: {
versions: { $slice: ["$versions", n] }, _id: 0,
}, secret: "$_id",
} versions: { $slice: ["$versions", n] },
])); },
},
]);
return latestNSecretVersions; return latestNSecretVersions;
} };
export { export { getLatestSecretVersionIds, getLatestNSecretSecretVersionIds };
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;
+16 -13
View File
@@ -1,15 +1,18 @@
import SecretSnapshot, { ISecretSnapshot } from './secretSnapshot'; import SecretSnapshot, { ISecretSnapshot } from "./secretSnapshot";
import SecretVersion, { ISecretVersion } from './secretVersion'; import SecretVersion, { ISecretVersion } from "./secretVersion";
import Log, { ILog } from './log'; import FolderVersion, { TFolderRootVersionSchema } from "./folderVersion";
import Action, { IAction } from './action'; import Log, { ILog } from "./log";
import Action, { IAction } from "./action";
export { export {
SecretSnapshot, SecretSnapshot,
ISecretSnapshot, ISecretSnapshot,
SecretVersion, SecretVersion,
ISecretVersion, ISecretVersion,
Log, FolderVersion,
ILog, TFolderRootVersionSchema,
Action, Log,
IAction ILog,
} Action,
IAction,
};
+44 -23
View File
@@ -1,33 +1,54 @@
import { Schema, model, Types } from 'mongoose'; import { Schema, model, Types } from "mongoose";
export interface ISecretSnapshot { export interface ISecretSnapshot {
workspace: Types.ObjectId; workspace: Types.ObjectId;
version: number; environment: string;
secretVersions: Types.ObjectId[]; folderId: string | "root";
version: number;
secretVersions: Types.ObjectId[];
folderVersion: Types.ObjectId;
} }
const secretSnapshotSchema = new Schema<ISecretSnapshot>( const secretSnapshotSchema = new Schema<ISecretSnapshot>(
{ {
workspace: { workspace: {
type: Schema.Types.ObjectId, type: Schema.Types.ObjectId,
ref: 'Workspace', ref: "Workspace",
required: true required: true,
},
version: {
type: Number,
required: true
},
secretVersions: [{
type: Schema.Types.ObjectId,
ref: 'SecretVersion',
required: true
}]
}, },
{ environment: {
timestamps: true type: String,
} required: true,
},
folderId: {
type: String,
default: "root",
},
version: {
type: Number,
default: 1,
required: true,
},
secretVersions: [
{
type: Schema.Types.ObjectId,
ref: "SecretVersion",
required: true,
},
],
folderVersion: {
type: Schema.Types.ObjectId,
ref: "FolderVersion",
},
},
{
timestamps: true,
}
); );
const SecretSnapshot = model<ISecretSnapshot>('SecretSnapshot', secretSnapshotSchema); const SecretSnapshot = model<ISecretSnapshot>(
"SecretSnapshot",
secretSnapshotSchema
);
export default SecretSnapshot; export default SecretSnapshot;
+116 -105
View File
@@ -1,115 +1,126 @@
import { Schema, model, Types } from 'mongoose'; import { Schema, model, Types } from "mongoose";
import { import {
SECRET_SHARED, SECRET_SHARED,
SECRET_PERSONAL, SECRET_PERSONAL,
ALGORITHM_AES_256_GCM, ALGORITHM_AES_256_GCM,
ENCODING_SCHEME_UTF8, ENCODING_SCHEME_UTF8,
ENCODING_SCHEME_BASE64 ENCODING_SCHEME_BASE64,
} from '../../variables'; } from "../../variables";
export interface ISecretVersion { export interface ISecretVersion {
_id: Types.ObjectId; _id: Types.ObjectId;
secret: Types.ObjectId; secret: Types.ObjectId;
version: number; version: number;
workspace: Types.ObjectId; // new workspace: Types.ObjectId; // new
type: string; // new type: string; // new
user?: Types.ObjectId; // new user?: Types.ObjectId; // new
environment: string; // new environment: string; // new
isDeleted: boolean; isDeleted: boolean;
secretBlindIndex?: string; secretBlindIndex?: string;
secretKeyCiphertext: string; secretKeyCiphertext: string;
secretKeyIV: string; secretKeyIV: string;
secretKeyTag: string; secretKeyTag: string;
secretValueCiphertext: string; secretValueCiphertext: string;
secretValueIV: string; secretValueIV: string;
secretValueTag: string; secretValueTag: string;
algorithm: 'aes-256-gcm'; algorithm: "aes-256-gcm";
keyEncoding: 'utf8' | 'base64'; keyEncoding: "utf8" | "base64";
createdAt: string;
folder?: string;
} }
const secretVersionSchema = new Schema<ISecretVersion>( const secretVersionSchema = new Schema<ISecretVersion>(
{ {
secret: { // could be deleted secret: {
type: Schema.Types.ObjectId, // could be deleted
ref: 'Secret', type: Schema.Types.ObjectId,
required: true ref: "Secret",
}, required: true,
version: { },
type: Number, version: {
default: 1, type: Number,
required: true default: 1,
}, required: true,
workspace: { },
type: Schema.Types.ObjectId, workspace: {
ref: 'Workspace', type: Schema.Types.ObjectId,
required: true ref: "Workspace",
}, required: true,
type: { },
type: String, type: {
enum: [SECRET_SHARED, SECRET_PERSONAL], type: String,
required: true enum: [SECRET_SHARED, SECRET_PERSONAL],
}, required: true,
user: { },
// user associated with the personal secret user: {
type: Schema.Types.ObjectId, // user associated with the personal secret
ref: 'User' type: Schema.Types.ObjectId,
}, ref: "User",
environment: { },
type: String, environment: {
required: true type: String,
}, required: true,
isDeleted: { // consider removing field },
type: Boolean, isDeleted: {
default: false, // consider removing field
required: true type: Boolean,
}, default: false,
secretBlindIndex: { required: true,
type: String, },
select: false secretBlindIndex: {
}, type: String,
secretKeyCiphertext: { select: false,
type: String, },
required: true secretKeyCiphertext: {
}, type: String,
secretKeyIV: { required: true,
type: String, // symmetric },
required: true secretKeyIV: {
}, type: String, // symmetric
secretKeyTag: { required: true,
type: String, // symmetric },
required: true secretKeyTag: {
}, type: String, // symmetric
secretValueCiphertext: { required: true,
type: String, },
required: true secretValueCiphertext: {
}, type: String,
secretValueIV: { required: true,
type: String, // symmetric },
required: true secretValueIV: {
}, type: String, // symmetric
secretValueTag: { required: true,
type: String, // symmetric },
required: true secretValueTag: {
}, type: String, // symmetric
algorithm: { // the encryption algorithm used required: true,
type: String, },
enum: [ALGORITHM_AES_256_GCM], algorithm: {
required: true // the encryption algorithm used
}, type: String,
keyEncoding: { enum: [ALGORITHM_AES_256_GCM],
type: String, required: true,
enum: [ default: ALGORITHM_AES_256_GCM,
ENCODING_SCHEME_UTF8, },
ENCODING_SCHEME_BASE64 keyEncoding: {
], type: String,
required: true enum: [ENCODING_SCHEME_UTF8, ENCODING_SCHEME_BASE64],
}, required: true,
}, default: ENCODING_SCHEME_UTF8,
{ },
timestamps: true folder: {
} type: String,
required: true,
},
},
{
timestamps: true,
}
); );
const SecretVersion = model<ISecretVersion>('SecretVersion', secretVersionSchema); const SecretVersion = model<ISecretVersion>(
"SecretVersion",
secretVersionSchema
);
export default SecretVersion; export default SecretVersion;
+66 -60
View File
@@ -1,76 +1,82 @@
import express from 'express'; import express from "express";
const router = express.Router(); const router = express.Router();
import { import {
requireAuth, requireAuth,
requireWorkspaceAuth, requireWorkspaceAuth,
validateRequest validateRequest,
} from '../../../middleware'; } from "../../../middleware";
import { param, query, body } from 'express-validator'; import { param, query, body } from "express-validator";
import { ADMIN, MEMBER } from '../../../variables'; import { ADMIN, MEMBER } from "../../../variables";
import { workspaceController } from '../../controllers/v1'; import { workspaceController } from "../../controllers/v1";
router.get( router.get(
'/:workspaceId/secret-snapshots', "/:workspaceId/secret-snapshots",
requireAuth({ requireAuth({
acceptedAuthModes: ['jwt', 'apiKey'] acceptedAuthModes: ["jwt", "apiKey"],
}), }),
requireWorkspaceAuth({ requireWorkspaceAuth({
acceptedRoles: [ADMIN, MEMBER], acceptedRoles: [ADMIN, MEMBER],
locationWorkspaceId: 'params' locationWorkspaceId: "params",
}), }),
param('workspaceId').exists().trim(), param("workspaceId").exists().trim(),
query('offset').exists().isInt(), query("environment").isString().exists().trim(),
query('limit').exists().isInt(), query("folderId").default("root").isString().trim(),
validateRequest, query("offset").exists().isInt(),
workspaceController.getWorkspaceSecretSnapshots query("limit").exists().isInt(),
validateRequest,
workspaceController.getWorkspaceSecretSnapshots
); );
router.get( router.get(
'/:workspaceId/secret-snapshots/count', "/:workspaceId/secret-snapshots/count",
requireAuth({ requireAuth({
acceptedAuthModes: ['jwt'] acceptedAuthModes: ["jwt"],
}), }),
requireWorkspaceAuth({ requireWorkspaceAuth({
acceptedRoles: [ADMIN, MEMBER], acceptedRoles: [ADMIN, MEMBER],
locationWorkspaceId: 'params' locationWorkspaceId: "params",
}), }),
param('workspaceId').exists().trim(), param("workspaceId").exists().trim(),
validateRequest, query("environment").isString().exists().trim(),
workspaceController.getWorkspaceSecretSnapshotsCount query("folderId").default("root").isString().trim(),
validateRequest,
workspaceController.getWorkspaceSecretSnapshotsCount
); );
router.post( router.post(
'/:workspaceId/secret-snapshots/rollback', "/:workspaceId/secret-snapshots/rollback",
requireAuth({ requireAuth({
acceptedAuthModes: ['jwt', 'apiKey'] acceptedAuthModes: ["jwt", "apiKey"],
}), }),
requireWorkspaceAuth({ requireWorkspaceAuth({
acceptedRoles: [ADMIN, MEMBER], acceptedRoles: [ADMIN, MEMBER],
locationWorkspaceId: 'params' locationWorkspaceId: "params",
}), }),
param('workspaceId').exists().trim(), param("workspaceId").exists().trim(),
body('version').exists().isInt(), body("environment").isString().exists().trim(),
validateRequest, query("folderId").default("root").isString().exists().trim(),
workspaceController.rollbackWorkspaceSecretSnapshot body("version").exists().isInt(),
validateRequest,
workspaceController.rollbackWorkspaceSecretSnapshot
); );
router.get( router.get(
'/:workspaceId/logs', "/:workspaceId/logs",
requireAuth({ requireAuth({
acceptedAuthModes: ['jwt', 'apiKey'] acceptedAuthModes: ["jwt", "apiKey"],
}), }),
requireWorkspaceAuth({ requireWorkspaceAuth({
acceptedRoles: [ADMIN, MEMBER], acceptedRoles: [ADMIN, MEMBER],
locationWorkspaceId: 'params' locationWorkspaceId: "params",
}), }),
param('workspaceId').exists().trim(), param("workspaceId").exists().trim(),
query('offset').exists().isInt(), query("offset").exists().isInt(),
query('limit').exists().isInt(), query("limit").exists().isInt(),
query('sortBy'), query("sortBy"),
query('userId'), query("userId"),
query('actionNames'), query("actionNames"),
validateRequest, validateRequest,
workspaceController.getWorkspaceLogs workspaceController.getWorkspaceLogs
); );
export default router; export default router;
+60 -53
View File
@@ -1,9 +1,9 @@
import { Types } from 'mongoose'; import { Types } from 'mongoose';
import { ISecretVersion } from '../models'; import { ISecretVersion } from '../models';
import { import {
takeSecretSnapshotHelper, takeSecretSnapshotHelper,
addSecretVersionsHelper, addSecretVersionsHelper,
markDeletedSecretVersionsHelper markDeletedSecretVersionsHelper,
} from '../helpers/secret'; } from '../helpers/secret';
import EELicenseService from './EELicenseService'; import EELicenseService from './EELicenseService';
@@ -11,58 +11,65 @@ import EELicenseService from './EELicenseService';
* Class to handle Enterprise Edition secret actions * Class to handle Enterprise Edition secret actions
*/ */
class EESecretService { 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
* SecretSnapshot collection.
* Requires a valid license key [licenseKey]
* @param {Object} obj
* @param {String} obj.workspaceId
* @returns {SecretSnapshot} secretSnapshot - new secret snpashot
*/
static async takeSecretSnapshot({
workspaceId,
environment,
folderId,
}: {
workspaceId: Types.ObjectId;
environment: string;
folderId?: string;
}) {
if (!EELicenseService.isLicenseValid) return;
return await takeSecretSnapshotHelper({
workspaceId,
environment,
folderId,
});
}
/** /**
* Save a secret snapshot that is a copy of the current state of secrets in workspace with id * Add secret versions [secretVersions] to the SecretVersion collection.
* [workspaceId] under a new snapshot with incremented version under the * @param {Object} obj
* SecretSnapshot collection. * @param {Object[]} obj.secretVersions
* Requires a valid license key [licenseKey] * @returns {SecretVersion[]} newSecretVersions - new secret versions
* @param {Object} obj */
* @param {String} obj.workspaceId static async addSecretVersions({
* @returns {SecretSnapshot} secretSnapshot - new secret snpashot secretVersions,
*/ }: {
static async takeSecretSnapshot({ secretVersions: ISecretVersion[];
workspaceId }) {
}: { if (!EELicenseService.isLicenseValid) return;
workspaceId: Types.ObjectId; return await addSecretVersionsHelper({
}) { secretVersions,
if (!EELicenseService.isLicenseValid) return; });
return await takeSecretSnapshotHelper({ workspaceId }); }
}
/** /**
* Add secret versions [secretVersions] to the SecretVersion collection. * Mark secret versions associated with secrets with ids [secretIds]
* @param {Object} obj * as deleted.
* @param {Object[]} obj.secretVersions * @param {Object} obj
* @returns {SecretVersion[]} newSecretVersions - new secret versions * @param {ObjectId[]} obj.secretIds - secret ids
*/ */
static async addSecretVersions({ static async markDeletedSecretVersions({
secretVersions secretIds,
}: { }: {
secretVersions: ISecretVersion[]; secretIds: Types.ObjectId[];
}) { }) {
if (!EELicenseService.isLicenseValid) return; if (!EELicenseService.isLicenseValid) return;
return await addSecretVersionsHelper({ await markDeletedSecretVersionsHelper({
secretVersions secretIds,
}); });
} }
/**
* Mark secret versions associated with secrets with ids [secretIds]
* as deleted.
* @param {Object} obj
* @param {ObjectId[]} obj.secretIds - secret ids
*/
static async markDeletedSecretVersions({
secretIds
}: {
secretIds: Types.ObjectId[];
}) {
if (!EELicenseService.isLicenseValid) return;
await markDeletedSecretVersionsHelper({
secretIds
});
}
} }
export default EESecretService; export default EESecretService;
+85 -93
View File
@@ -1,13 +1,7 @@
import * as Sentry from '@sentry/node'; import * as Sentry from '@sentry/node';
import { Types } from 'mongoose'; import { Types } from 'mongoose';
import { import { Membership, Key } from '../models';
Membership, import { MembershipNotFoundError, BadRequestError } from '../utils/errors';
Key
} from '../models';
import {
MembershipNotFoundError,
BadRequestError
} from '../utils/errors';
/** /**
* Validate that user with id [userId] is a member of workspace with id [workspaceId] * Validate that user with id [userId] is a member of workspace with id [workspaceId]
@@ -18,32 +12,35 @@ import {
* @returns {Membership} membership - membership of user with id [userId] for workspace with id [workspaceId] * @returns {Membership} membership - membership of user with id [userId] for workspace with id [workspaceId]
*/ */
const validateMembership = async ({ const validateMembership = async ({
userId, userId,
workspaceId, workspaceId,
acceptedRoles, acceptedRoles,
}: { }: {
userId: Types.ObjectId; userId: Types.ObjectId | string;
workspaceId: Types.ObjectId; workspaceId: Types.ObjectId | string;
acceptedRoles?: Array<'admin' | 'member'>; acceptedRoles?: Array<'admin' | 'member'>;
}) => { }) => {
const membership = await Membership.findOne({
user: userId,
workspace: workspaceId,
}).populate('workspace');
const membership = await Membership.findOne({ if (!membership) {
user: userId, throw MembershipNotFoundError({
workspace: workspaceId message: 'Failed to find workspace membership',
}).populate("workspace"); });
}
if (!membership) { if (acceptedRoles) {
throw MembershipNotFoundError({ message: 'Failed to find workspace membership' }); if (!acceptedRoles.includes(membership.role)) {
} throw BadRequestError({
message: 'Failed authorization for membership role',
});
}
}
if (acceptedRoles) { return membership;
if (!acceptedRoles.includes(membership.role)) { };
throw BadRequestError({ message: 'Failed authorization for membership role' });
}
}
return membership;
}
/** /**
* Return membership matching criteria specified in query [queryObj] * Return membership matching criteria specified in query [queryObj]
@@ -51,16 +48,16 @@ const validateMembership = async ({
* @return {Object} membership - membership * @return {Object} membership - membership
*/ */
const findMembership = async (queryObj: any) => { const findMembership = async (queryObj: any) => {
let membership; let membership;
try { try {
membership = await Membership.findOne(queryObj); membership = await Membership.findOne(queryObj);
} catch (err) { } catch (err) {
Sentry.setUser(null); Sentry.setUser(null);
Sentry.captureException(err); Sentry.captureException(err);
throw new Error('Failed to find membership'); throw new Error('Failed to find membership');
} }
return membership; return membership;
}; };
/** /**
@@ -72,39 +69,39 @@ const findMembership = async (queryObj: any) => {
* @param {String[]} obj.roles - roles of users. * @param {String[]} obj.roles - roles of users.
*/ */
const addMemberships = async ({ const addMemberships = async ({
userIds, userIds,
workspaceId, workspaceId,
roles roles,
}: { }: {
userIds: string[]; userIds: string[];
workspaceId: string; workspaceId: string;
roles: string[]; roles: string[];
}): Promise<void> => { }): Promise<void> => {
try { try {
const operations = userIds.map((userId, idx) => { const operations = userIds.map((userId, idx) => {
return { return {
updateOne: { updateOne: {
filter: { filter: {
user: userId, user: userId,
workspace: workspaceId, workspace: workspaceId,
role: roles[idx] role: roles[idx],
}, },
update: { update: {
user: userId, user: userId,
workspace: workspaceId, workspace: workspaceId,
role: roles[idx] role: roles[idx],
}, },
upsert: true upsert: true,
} },
}; };
}); });
await Membership.bulkWrite(operations as any); await Membership.bulkWrite(operations as any);
} catch (err) { } catch (err) {
Sentry.setUser(null); Sentry.setUser(null);
Sentry.captureException(err); Sentry.captureException(err);
throw new Error('Failed to add users to workspace'); throw new Error('Failed to add users to workspace');
} }
}; };
/** /**
@@ -113,32 +110,27 @@ const addMemberships = async ({
* @param {String} obj.membershipId - id of membership to delete * @param {String} obj.membershipId - id of membership to delete
*/ */
const deleteMembership = async ({ membershipId }: { membershipId: string }) => { const deleteMembership = async ({ membershipId }: { membershipId: string }) => {
let deletedMembership; let deletedMembership;
try { try {
deletedMembership = await Membership.findOneAndDelete({ deletedMembership = await Membership.findOneAndDelete({
_id: membershipId _id: membershipId,
}); });
// delete keys associated with the membership // delete keys associated with the membership
if (deletedMembership?.user) { if (deletedMembership?.user) {
// case: membership had a registered user // case: membership had a registered user
await Key.deleteMany({ await Key.deleteMany({
receiver: deletedMembership.user, receiver: deletedMembership.user,
workspace: deletedMembership.workspace workspace: deletedMembership.workspace,
}); });
} }
} catch (err) { } catch (err) {
Sentry.setUser(null); Sentry.setUser(null);
Sentry.captureException(err); Sentry.captureException(err);
throw new Error('Failed to delete membership'); throw new Error('Failed to delete membership');
} }
return deletedMembership; return deletedMembership;
}; };
export { export { validateMembership, addMemberships, findMembership, deleteMembership };
validateMembership,
addMemberships,
findMembership,
deleteMembership
};
+8 -8
View File
@@ -1,5 +1,5 @@
import { Types } from "mongoose"; import { Types } from "mongoose";
import { Secret, ISecret, Membership } from "../models"; import { Secret, ISecret } from "../models";
import { EESecretService, EELogService } from "../ee/services"; import { EESecretService, EELogService } from "../ee/services";
import { IAction, SecretVersion } from "../ee/models"; import { IAction, SecretVersion } from "../ee/models";
import { import {
@@ -12,8 +12,6 @@ import {
ALGORITHM_AES_256_GCM, ALGORITHM_AES_256_GCM,
ENCODING_SCHEME_UTF8, ENCODING_SCHEME_UTF8,
} from "../variables"; } from "../variables";
import _ from "lodash";
import { BadRequestError, UnauthorizedRequestError } from "../utils/errors";
interface V1PushSecret { interface V1PushSecret {
ciphertextKey: string; ciphertextKey: string;
@@ -197,7 +195,7 @@ const v1PushSecrets = async ({
secretValueTag: newSecret.tagValue, secretValueTag: newSecret.tagValue,
secretValueHash: newSecret.hashValue, secretValueHash: newSecret.hashValue,
algorithm: ALGORITHM_AES_256_GCM, algorithm: ALGORITHM_AES_256_GCM,
keyEncoding: ENCODING_SCHEME_UTF8 keyEncoding: ENCODING_SCHEME_UTF8,
}); });
}), }),
}); });
@@ -230,7 +228,7 @@ const v1PushSecrets = async ({
secretCommentTag: s.tagComment, secretCommentTag: s.tagComment,
secretCommentHash: s.hashComment, secretCommentHash: s.hashComment,
algorithm: ALGORITHM_AES_256_GCM, algorithm: ALGORITHM_AES_256_GCM,
keyEncoding: ENCODING_SCHEME_UTF8 keyEncoding: ENCODING_SCHEME_UTF8,
}; };
if (toAdd[idx].type === "personal") { if (toAdd[idx].type === "personal") {
@@ -261,7 +259,7 @@ const v1PushSecrets = async ({
secretValueTag, secretValueTag,
secretValueHash, secretValueHash,
algorithm, algorithm,
keyEncoding keyEncoding,
}) => }) =>
new SecretVersion({ new SecretVersion({
secret: _id, secret: _id,
@@ -280,7 +278,7 @@ const v1PushSecrets = async ({
secretValueTag, secretValueTag,
secretValueHash, secretValueHash,
algorithm, algorithm,
keyEncoding keyEncoding,
}) })
), ),
}); });
@@ -289,6 +287,7 @@ const v1PushSecrets = async ({
// (EE) take a secret snapshot // (EE) take a secret snapshot
await EESecretService.takeSecretSnapshot({ await EESecretService.takeSecretSnapshot({
workspaceId: new Types.ObjectId(workspaceId), workspaceId: new Types.ObjectId(workspaceId),
environment,
}); });
}; };
@@ -491,7 +490,7 @@ const v2PushSecrets = async ({
secret: secretDocument._id, secret: secretDocument._id,
isDeleted: false, isDeleted: false,
algorithm: ALGORITHM_AES_256_GCM, 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 // (EE) take a secret snapshot
await EESecretService.takeSecretSnapshot({ await EESecretService.takeSecretSnapshot({
workspaceId: new Types.ObjectId(workspaceId), workspaceId: new Types.ObjectId(workspaceId),
environment,
}); });
// (EE) create (audit) log // (EE) create (audit) log
File diff suppressed because it is too large Load Diff
+18 -21
View File
@@ -1,8 +1,6 @@
import dotenv from "dotenv"; import dotenv from 'dotenv';
dotenv.config(); dotenv.config();
import express from 'express'; import express from 'express';
// eslint-disable-next-line @typescript-eslint/no-var-requires
require('express-async-errors');
import helmet from 'helmet'; import helmet from 'helmet';
import cors from 'cors'; import cors from 'cors';
import { DatabaseService } from './services'; import { DatabaseService } from './services';
@@ -21,7 +19,7 @@ import {
secretSnapshot as eeSecretSnapshotRouter, secretSnapshot as eeSecretSnapshotRouter,
action as eeActionRouter, action as eeActionRouter,
organizations as eeOrganizationsRouter, organizations as eeOrganizationsRouter,
cloudProducts as eeCloudProductsRouter cloudProducts as eeCloudProductsRouter,
} from './ee/routes/v1'; } from './ee/routes/v1';
import { import {
signup as v1SignupRouter, signup as v1SignupRouter,
@@ -41,7 +39,7 @@ import {
stripe as v1StripeRouter, stripe as v1StripeRouter,
integration as v1IntegrationRouter, integration as v1IntegrationRouter,
integrationAuth as v1IntegrationAuthRouter, integrationAuth as v1IntegrationAuthRouter,
secretsFolder as v1SecretsFolder secretsFolder as v1SecretsFolder,
} from './routes/v1'; } from './routes/v1';
import { import {
signup as v2SignupRouter, signup as v2SignupRouter,
@@ -61,17 +59,13 @@ import {
auth as v3AuthRouter, auth as v3AuthRouter,
secrets as v3SecretsRouter, secrets as v3SecretsRouter,
signup as v3SignupRouter, signup as v3SignupRouter,
workspaces as v3WorkspacesRouter workspaces as v3WorkspacesRouter,
} from './routes/v3'; } from './routes/v3';
import { healthCheck } from './routes/status'; import { healthCheck } from './routes/status';
import { getLogger } from './utils/logger'; import { getLogger } from './utils/logger';
import { RouteNotFoundError } from './utils/errors'; import { RouteNotFoundError } from './utils/errors';
import { requestErrorHandler } from './middleware/requestErrorHandler'; import { requestErrorHandler } from './middleware/requestErrorHandler';
import { import { getNodeEnv, getPort, getSiteURL } from './config';
getNodeEnv,
getPort,
getSiteURL
} from './config';
import { setup } from './utils/setup'; import { setup } from './utils/setup';
const main = async () => { const main = async () => {
@@ -86,7 +80,7 @@ const main = async () => {
app.use( app.use(
cors({ cors({
credentials: true, 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/stripe', v1StripeRouter);
app.use('/api/v1/integration', v1IntegrationRouter); app.use('/api/v1/integration', v1IntegrationRouter);
app.use('/api/v1/integration-auth', v1IntegrationAuthRouter); app.use('/api/v1/integration-auth', v1IntegrationAuthRouter);
app.use('/api/v1/folder', v1SecretsFolder) app.use('/api/v1/folders', v1SecretsFolder);
// v2 routes (improvements) // v2 routes (improvements)
app.use('/api/v2/signup', v2SignupRouter); app.use('/api/v2/signup', v2SignupRouter);
@@ -143,34 +137,37 @@ const main = async () => {
app.use('/api/v2/api-key', v2APIKeyDataRouter); app.use('/api/v2/api-key', v2APIKeyDataRouter);
// v3 routes (experimental) // v3 routes (experimental)
app.use("/api/v3/auth", v3AuthRouter);
app.use('/api/v3/secrets', v3SecretsRouter); app.use('/api/v3/secrets', v3SecretsRouter);
app.use('/api/v3/workspaces', v3WorkspacesRouter); app.use('/api/v3/workspaces', v3WorkspacesRouter);
app.use("/api/v3/signup", v3SignupRouter);
// api docs // api docs
app.use('/api-docs', swaggerUi.serve, swaggerUi.setup(swaggerFile)) app.use('/api-docs', swaggerUi.serve, swaggerUi.setup(swaggerFile));
// server status // server status
app.use('/api', healthCheck) app.use('/api', healthCheck);
//* Handle unrouted requests and respond with proper error message as well as status code //* Handle unrouted requests and respond with proper error message as well as status code
app.use((req, res, next) => { app.use((req, res, next) => {
if (res.headersSent) return 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); app.use(requestErrorHandler);
const server = app.listen(await getPort(), async () => { const server = app.listen(await getPort(), async () => {
(await getLogger("backend-main")).info( (await getLogger('backend-main')).info(
`Server started listening at port ${await getPort()}` `Server started listening at port ${await getPort()}`
); );
}); });
// await createTestUserForDevelopment();
setUpHealthEndpoint(server); setUpHealthEndpoint(server);
server.on("close", async () => { server.on('close', async () => {
await DatabaseService.closeDatabase(); await DatabaseService.closeDatabase();
}); });
@@ -1,52 +1,54 @@
import { Types } from 'mongoose'; import { Types } from "mongoose";
import { AuthData } from '../../middleware'; import { AuthData } from "../../middleware";
export interface CreateSecretParams { export interface CreateSecretParams {
secretName: string; secretName: string;
workspaceId: Types.ObjectId; workspaceId: Types.ObjectId;
environment: string; environment: string;
type: 'shared' | 'personal'; folderId?: string;
authData: AuthData; type: "shared" | "personal";
secretKeyCiphertext: string; authData: AuthData;
secretKeyIV: string; secretKeyCiphertext: string;
secretKeyTag: string; secretKeyIV: string;
secretValueCiphertext: string; secretKeyTag: string;
secretValueIV: string; secretValueCiphertext: string;
secretValueTag: string; secretValueIV: string;
secretCommentCiphertext?: string; secretValueTag: string;
secretCommentIV?: string; secretCommentCiphertext?: string;
secretCommentTag?: string; secretCommentIV?: string;
secretCommentTag?: string;
} }
export interface GetSecretsParams { export interface GetSecretsParams {
workspaceId: Types.ObjectId; workspaceId: Types.ObjectId;
environment: string; environment: string;
authData: AuthData; authData: AuthData;
} }
export interface GetSecretParams { export interface GetSecretParams {
secretName: string; secretName: string;
workspaceId: Types.ObjectId; workspaceId: Types.ObjectId;
environment: string; environment: string;
type?: 'shared' | 'personal'; type?: "shared" | "personal";
authData: AuthData; authData: AuthData;
} }
export interface UpdateSecretParams { export interface UpdateSecretParams {
secretName: string; secretName: string;
workspaceId: Types.ObjectId; workspaceId: Types.ObjectId;
environment: string; environment: string;
type: 'shared' | 'personal', type: "shared" | "personal";
authData: AuthData authData: AuthData;
secretValueCiphertext: string; secretValueCiphertext: string;
secretValueIV: string; secretValueIV: string;
secretValueTag: string; secretValueTag: string;
folderId?: string;
} }
export interface DeleteSecretParams { export interface DeleteSecretParams {
secretName: string; secretName: string;
workspaceId: Types.ObjectId; workspaceId: Types.ObjectId;
environment: string; environment: string;
type: 'shared' | 'personal'; type: "shared" | "personal";
authData: AuthData; authData: AuthData;
} }
+40 -24
View File
@@ -1,29 +1,45 @@
import * as Sentry from '@sentry/node'; import * as Sentry from '@sentry/node';
import { ErrorRequestHandler } from "express"; import { ErrorRequestHandler } from 'express';
import { InternalServerError } from "../utils/errors"; import { InternalServerError } from '../utils/errors';
import { getLogger } from "../utils/logger"; import { getLogger } from '../utils/logger';
import RequestError, { LogLevel } from "../utils/requestError"; import RequestError, { LogLevel } from '../utils/requestError';
import { getNodeEnv } from '../config';
export const requestErrorHandler: ErrorRequestHandler = async (error: RequestError | Error, req, res, next) => { export const requestErrorHandler: ErrorRequestHandler = async (
if (res.headersSent) return next(); 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 //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)) { if (!(error instanceof RequestError)) {
error = InternalServerError({ context: { exception: error.message }, stack: error.stack }); error = InternalServerError({
(await getLogger('backend-main')).log((<RequestError>error).levelName.toLowerCase(), (<RequestError>error).message) 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 //* Set Sentry user identification if req.user is populated
if (req.user !== undefined && req.user !== null) { 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' //* 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 //* 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)) { if (
Sentry.captureException(error) [LogLevel.ERROR, LogLevel.EMERGENCY, LogLevel.CRITICAL].includes(
} (<RequestError>error).level
)
) {
Sentry.captureException(error);
}
res.status((<RequestError>error).statusCode).json((<RequestError>error).format(req)) res
next() .status((<RequestError>error).statusCode)
} .json((<RequestError>error).format(req));
next();
};
+47 -27
View File
@@ -1,36 +1,56 @@
import { Schema, Types, model } from 'mongoose'; import { Schema, model, Types } from "mongoose";
const folderSchema = new Schema({ 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: { name: {
type: String,
required: true, required: true,
},
workspace: {
type: Schema.Types.ObjectId,
ref: 'Workspace',
required: true,
},
environment: {
type: String, type: String,
required: true, default: "root",
}, },
parent: {
type: Schema.Types.ObjectId,
ref: 'Folder',
required: false, // optional for root folders
},
path: {
type: String,
required: true
},
parentPath: {
type: String,
required: true,
},
}, {
timestamps: true
}); });
const Folder = model('Folder', folderSchema); folderSchema.add({ children: [folderSchema] });
const folderRootSchema = new Schema<TFolderRootSchema>(
{
workspace: {
type: Schema.Types.ObjectId,
ref: "Workspace",
required: true,
},
environment: {
type: String,
required: true,
},
nodes: folderSchema,
},
{
timestamps: true,
}
);
const Folder = model<TFolderRootSchema>("Folder", folderRootSchema);
export default Folder; export default Folder;
+133 -143
View File
@@ -1,154 +1,144 @@
import { Schema, model, Types } from 'mongoose'; import { Schema, model, Types } from "mongoose";
import { import {
SECRET_SHARED, SECRET_SHARED,
SECRET_PERSONAL, SECRET_PERSONAL,
ALGORITHM_AES_256_GCM, ALGORITHM_AES_256_GCM,
ENCODING_SCHEME_UTF8, ENCODING_SCHEME_UTF8,
ENCODING_SCHEME_BASE64 ENCODING_SCHEME_BASE64,
} from '../variables'; } from "../variables";
import { ROOT_FOLDER_PATH } from '../utils/folder';
export interface ISecret { export interface ISecret {
_id: Types.ObjectId; _id: Types.ObjectId;
version: number; version: number;
workspace: Types.ObjectId; workspace: Types.ObjectId;
type: string; type: string;
user: Types.ObjectId; user: Types.ObjectId;
environment: string; environment: string;
secretBlindIndex?: string; secretBlindIndex?: string;
secretKeyCiphertext: string; secretKeyCiphertext: string;
secretKeyIV: string; secretKeyIV: string;
secretKeyTag: string; secretKeyTag: string;
secretKeyHash: string; secretKeyHash: string;
secretValueCiphertext: string; secretValueCiphertext: string;
secretValueIV: string; secretValueIV: string;
secretValueTag: string; secretValueTag: string;
secretValueHash: string; secretValueHash: string;
secretCommentCiphertext?: string; secretCommentCiphertext?: string;
secretCommentIV?: string; secretCommentIV?: string;
secretCommentTag?: string; secretCommentTag?: string;
secretCommentHash?: string; secretCommentHash?: string;
algorithm: 'aes-256-gcm'; algorithm: "aes-256-gcm";
keyEncoding: 'utf8' | 'base64'; keyEncoding: "utf8" | "base64";
tags?: string[]; tags?: string[];
path?: string; folder?: string;
folder?: Types.ObjectId;
} }
const secretSchema = new Schema<ISecret>( const secretSchema = new Schema<ISecret>(
{ {
version: { version: {
type: Number, type: Number,
required: true, required: true,
default: 1 default: 1,
}, },
workspace: { workspace: {
type: Schema.Types.ObjectId, type: Schema.Types.ObjectId,
ref: 'Workspace', ref: "Workspace",
required: true required: true,
}, },
type: { type: {
type: String, type: String,
enum: [SECRET_SHARED, SECRET_PERSONAL], enum: [SECRET_SHARED, SECRET_PERSONAL],
required: true required: true,
}, },
user: { user: {
// user associated with the personal secret // user associated with the personal secret
type: Schema.Types.ObjectId, type: Schema.Types.ObjectId,
ref: 'User' ref: "User",
}, },
tags: { tags: {
ref: 'Tag', ref: "Tag",
type: [Schema.Types.ObjectId], type: [Schema.Types.ObjectId],
default: [] default: [],
}, },
environment: { environment: {
type: String, type: String,
required: true required: true,
}, },
secretBlindIndex: { secretBlindIndex: {
type: String, type: String,
select: false select: false,
}, },
secretKeyCiphertext: { secretKeyCiphertext: {
type: String, type: String,
required: true required: true,
}, },
secretKeyIV: { secretKeyIV: {
type: String, // symmetric type: String, // symmetric
required: true required: true,
}, },
secretKeyTag: { secretKeyTag: {
type: String, // symmetric type: String, // symmetric
required: true required: true,
}, },
secretKeyHash: { secretKeyHash: {
type: String type: String,
}, },
secretValueCiphertext: { secretValueCiphertext: {
type: String, type: String,
required: true required: true,
}, },
secretValueIV: { secretValueIV: {
type: String, // symmetric type: String, // symmetric
required: true required: true,
}, },
secretValueTag: { secretValueTag: {
type: String, // symmetric type: String, // symmetric
required: true required: true,
}, },
secretValueHash: { secretValueHash: {
type: String type: String,
}, },
secretCommentCiphertext: { secretCommentCiphertext: {
type: String, type: String,
required: false required: false,
}, },
secretCommentIV: { secretCommentIV: {
type: String, // symmetric type: String, // symmetric
required: false required: false,
}, },
secretCommentTag: { secretCommentTag: {
type: String, // symmetric type: String, // symmetric
required: false required: false,
}, },
secretCommentHash: { secretCommentHash: {
type: String, type: String,
required: false required: false,
}, },
algorithm: { // the encryption algorithm used algorithm: {
type: String, // the encryption algorithm used
enum: [ALGORITHM_AES_256_GCM], type: String,
required: true enum: [ALGORITHM_AES_256_GCM],
}, required: true,
keyEncoding: { default: ALGORITHM_AES_256_GCM,
type: String, },
enum: [ keyEncoding: {
ENCODING_SCHEME_UTF8, type: String,
ENCODING_SCHEME_BASE64 enum: [ENCODING_SCHEME_UTF8, ENCODING_SCHEME_BASE64],
], required: true,
required: true default: ENCODING_SCHEME_UTF8,
}, },
// the full path to the secret in relation to folders folder: {
path: { type: String,
type: String, default: "root",
required: false, },
default: ROOT_FOLDER_PATH },
}, {
folder: { timestamps: true,
type: Schema.Types.ObjectId, }
ref: 'Folder',
required: false,
},
},
{
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; 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(); const router = express.Router();
import { import {
requireAuth, requireAuth,
requireWorkspaceAuth, requireWorkspaceAuth,
validateRequest validateRequest,
} from '../../middleware'; } from "../../middleware";
import { body, param } from 'express-validator'; import { body, param, query } from "express-validator";
import { createFolder, deleteFolder, getFolderById } from '../../controllers/v1/secretsFolderController'; import {
import { ADMIN, MEMBER } from '../../variables'; createFolder,
deleteFolder,
getFolders,
updateFolderById,
} from "../../controllers/v1/secretsFolderController";
import { ADMIN, MEMBER } from "../../variables";
router.post( router.post(
'/', "/",
requireAuth({ requireAuth({
acceptedAuthModes: ['jwt'] acceptedAuthModes: ["jwt"],
}), }),
requireWorkspaceAuth({ requireWorkspaceAuth({
acceptedRoles: [ADMIN, MEMBER], acceptedRoles: [ADMIN, MEMBER],
locationWorkspaceId: 'body' locationWorkspaceId: "body",
}), }),
body('workspaceId').exists(), body("workspaceId").exists(),
body('environment').exists(), body("environment").exists(),
body('folderName').exists(), body("folderName").exists(),
body('parentFolderId'), body("parentFolderId"),
validateRequest, validateRequest,
createFolder createFolder
); );
router.delete( router.patch(
'/:folderId', "/:folderId",
requireAuth({ 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, validateRequest,
deleteFolder deleteFolder
); );
router.get( router.get(
'/:folderId', "/",
requireAuth({ requireAuth({
acceptedAuthModes: ['jwt'] acceptedAuthModes: ["jwt"],
}), }),
param('folderId').exists(), query("workspaceId").exists().isString().trim(),
query("environment").exists().isString().trim(),
query("parentFolderId").optional().isString().trim(),
validateRequest, validateRequest,
getFolderById getFolders
); );
export default router; export default router;
+200 -171
View File
@@ -2,204 +2,233 @@ import express from 'express';
const router = express.Router(); const router = express.Router();
import { Types } from 'mongoose'; import { Types } from 'mongoose';
import { import {
requireAuth, requireAuth,
requireWorkspaceAuth, requireWorkspaceAuth,
requireSecretsAuth, requireSecretsAuth,
validateRequest validateRequest,
} from '../../middleware'; } from '../../middleware';
import { validateClientForSecrets } from '../../validation'; import { validateClientForSecrets } from '../../validation';
import { query, body } from 'express-validator'; import { query, body } from 'express-validator';
import { secretsController } from '../../controllers/v2'; import { secretsController } from '../../controllers/v2';
import { import {
ADMIN, ADMIN,
MEMBER, MEMBER,
SECRET_PERSONAL, SECRET_PERSONAL,
SECRET_SHARED, SECRET_SHARED,
PERMISSION_READ_SECRETS, PERMISSION_READ_SECRETS,
PERMISSION_WRITE_SECRETS, PERMISSION_WRITE_SECRETS,
AUTH_MODE_JWT, AUTH_MODE_JWT,
AUTH_MODE_SERVICE_ACCOUNT, AUTH_MODE_SERVICE_ACCOUNT,
AUTH_MODE_SERVICE_TOKEN, AUTH_MODE_SERVICE_TOKEN,
AUTH_MODE_API_KEY AUTH_MODE_API_KEY,
} from '../../variables'; } from '../../variables';
import { import { BatchSecretRequest } from '../../types/secret';
BatchSecretRequest
} from '../../types/secret';
router.post( router.post(
'/batch', '/batch',
requireAuth({ requireAuth({
acceptedAuthModes: [AUTH_MODE_JWT, AUTH_MODE_API_KEY, AUTH_MODE_SERVICE_TOKEN] acceptedAuthModes: [
}), AUTH_MODE_JWT,
requireWorkspaceAuth({ AUTH_MODE_API_KEY,
acceptedRoles: [ADMIN, MEMBER], AUTH_MODE_SERVICE_TOKEN,
locationWorkspaceId: 'body' ],
}), }),
body('workspaceId').exists().isString().trim(), requireWorkspaceAuth({
body('environment').exists().isString().trim(), acceptedRoles: [ADMIN, MEMBER],
body('requests') locationWorkspaceId: 'body',
.exists() }),
.custom(async (requests: BatchSecretRequest[], { req }) => { body('workspaceId').exists().isString().trim(),
if (Array.isArray(requests)) { body('folderId').default('root').isString().trim(),
const secretIds = requests body('environment').exists().isString().trim(),
.map((request) => request.secret._id) body('requests')
.filter((secretId) => secretId !== undefined) .exists()
.custom(async (requests: BatchSecretRequest[], { req }) => {
if (Array.isArray(requests)) {
const secretIds = requests
.map((request) => request.secret._id)
.filter((secretId) => secretId !== undefined);
if (secretIds.length > 0) { if (secretIds.length > 0) {
req.secrets = await validateClientForSecrets({ req.secrets = await validateClientForSecrets({
authData: req.authData, authData: req.authData,
secretIds: secretIds.map((secretId: string) => new Types.ObjectId(secretId)), secretIds: secretIds.map(
requiredPermissions: [] (secretId: string) => new Types.ObjectId(secretId)
}); ),
} requiredPermissions: [],
} });
return true; }
}), }
validateRequest, return true;
secretsController.batchSecrets }),
validateRequest,
secretsController.batchSecrets
); );
router.post( router.post(
'/', '/',
body('workspaceId').exists().isString().trim(), body('workspaceId').exists().isString().trim(),
body('environment').exists().isString().trim(), body('environment').exists().isString().trim(),
body('secrets') body('folderId').default('root').isString().trim(),
.exists() body('secrets')
.custom((value) => { .exists()
if (Array.isArray(value)) { .custom((value) => {
// case: create multiple secrets if (Array.isArray(value)) {
if (value.length === 0) throw new Error('secrets cannot be an empty array') // case: create multiple secrets
for (const secret of value) { if (value.length === 0)
if ( throw new Error('secrets cannot be an empty array');
!secret.type || for (const secret of value) {
!(secret.type === SECRET_PERSONAL || secret.type === SECRET_SHARED) || if (
!secret.secretKeyCiphertext || !secret.type ||
!secret.secretKeyIV || !(
!secret.secretKeyTag || secret.type === SECRET_PERSONAL || secret.type === SECRET_SHARED
(typeof secret.secretValueCiphertext !== 'string') || ) ||
!secret.secretValueIV || !secret.secretKeyCiphertext ||
!secret.secretValueTag !secret.secretKeyIV ||
) { !secret.secretKeyTag ||
throw new Error('secrets array must contain objects that have required secret properties'); typeof secret.secretValueCiphertext !== 'string' ||
} !secret.secretValueIV ||
} !secret.secretValueTag
} else if (typeof value === 'object') { ) {
// case: update 1 secret throw new Error(
if ( 'secrets array must contain objects that have required secret properties'
!value.type || );
!(value.type === SECRET_PERSONAL || value.type === SECRET_SHARED) || }
!value.secretKeyCiphertext || }
!value.secretKeyIV || } else if (typeof value === 'object') {
!value.secretKeyTag || // case: update 1 secret
!value.secretValueCiphertext || if (
!value.secretValueIV || !value.type ||
!value.secretValueTag !(value.type === SECRET_PERSONAL || value.type === SECRET_SHARED) ||
) { !value.secretKeyCiphertext ||
throw new Error('secrets object is missing required secret properties'); !value.secretKeyIV ||
} !value.secretKeyTag ||
} else { !value.secretValueCiphertext ||
throw new Error('secrets must be an object or an array of objects') !value.secretValueIV ||
} !value.secretValueTag
) {
throw new Error(
'secrets object is missing required secret properties'
);
}
} else {
throw new Error('secrets must be an object or an array of objects');
}
return true; return true;
}),
validateRequest,
requireAuth({
acceptedAuthModes: [AUTH_MODE_JWT, AUTH_MODE_API_KEY, AUTH_MODE_SERVICE_TOKEN]
}), }),
requireWorkspaceAuth({ validateRequest,
acceptedRoles: [ADMIN, MEMBER], requireAuth({
locationWorkspaceId: 'body', acceptedAuthModes: [
locationEnvironment: 'body', AUTH_MODE_JWT,
requiredPermissions: [PERMISSION_WRITE_SECRETS] AUTH_MODE_API_KEY,
}), AUTH_MODE_SERVICE_TOKEN,
secretsController.createSecrets ],
}),
requireWorkspaceAuth({
acceptedRoles: [ADMIN, MEMBER],
locationWorkspaceId: 'body',
locationEnvironment: 'body',
requiredPermissions: [PERMISSION_WRITE_SECRETS],
}),
secretsController.createSecrets
); );
router.get( router.get(
'/', '/',
query('workspaceId').exists().trim(), query('workspaceId').exists().trim(),
query('environment').exists().trim(), query('environment').exists().trim(),
query('tagSlugs'), query('tagSlugs'),
validateRequest, query('folderId').default('root').isString().trim(),
requireAuth({ validateRequest,
acceptedAuthModes: [AUTH_MODE_JWT, AUTH_MODE_API_KEY, AUTH_MODE_SERVICE_TOKEN, AUTH_MODE_SERVICE_ACCOUNT] requireAuth({
}), acceptedAuthModes: [
requireWorkspaceAuth({ AUTH_MODE_JWT,
acceptedRoles: [ADMIN, MEMBER], AUTH_MODE_API_KEY,
locationWorkspaceId: 'query', AUTH_MODE_SERVICE_TOKEN,
locationEnvironment: 'query', AUTH_MODE_SERVICE_ACCOUNT,
requiredPermissions: [PERMISSION_READ_SECRETS] ],
}), }),
secretsController.getSecrets requireWorkspaceAuth({
acceptedRoles: [ADMIN, MEMBER],
locationWorkspaceId: 'query',
locationEnvironment: 'query',
requiredPermissions: [PERMISSION_READ_SECRETS],
}),
secretsController.getSecrets
); );
router.patch( router.patch(
'/', '/',
body('secrets') body('secrets')
.exists() .exists()
.custom((value) => { .custom((value) => {
if (Array.isArray(value)) { if (Array.isArray(value)) {
// case: update multiple secrets // case: update multiple secrets
if (value.length === 0) throw new Error('secrets cannot be an empty array') if (value.length === 0)
for (const secret of value) { throw new Error('secrets cannot be an empty array');
if ( for (const secret of value) {
!secret.id if (!secret.id) {
) { throw new Error('Each secret must contain a ID property');
throw new Error('Each secret must contain a ID property'); }
} }
} } else if (typeof value === 'object') {
} else if (typeof value === 'object') { // case: update 1 secret
// case: update 1 secret if (!value.id) {
if ( throw new Error('secret must contain a ID property');
!value.id }
) { } else {
throw new Error('secret must contain a ID property'); throw new Error('secrets must be an object or an array of objects');
} }
} else {
throw new Error('secrets must be an object or an array of objects')
}
return true; return true;
}),
validateRequest,
requireAuth({
acceptedAuthModes: [AUTH_MODE_JWT, AUTH_MODE_API_KEY, AUTH_MODE_SERVICE_TOKEN]
}), }),
requireSecretsAuth({ validateRequest,
acceptedRoles: [ADMIN, MEMBER], requireAuth({
requiredPermissions: [PERMISSION_WRITE_SECRETS] acceptedAuthModes: [
}), AUTH_MODE_JWT,
secretsController.updateSecrets AUTH_MODE_API_KEY,
AUTH_MODE_SERVICE_TOKEN,
],
}),
requireSecretsAuth({
acceptedRoles: [ADMIN, MEMBER],
requiredPermissions: [PERMISSION_WRITE_SECRETS],
}),
secretsController.updateSecrets
); );
router.delete( router.delete(
'/', '/',
body('secretIds') body('secretIds')
.exists() .exists()
.custom((value) => { .custom((value) => {
// case: delete 1 secret // case: delete 1 secret
if (typeof value === 'string') return true; if (typeof value === 'string') return true;
if (Array.isArray(value)) { if (Array.isArray(value)) {
// case: delete multiple secrets // case: delete multiple secrets
if (value.length === 0) throw new Error('secrets cannot be an empty array'); if (value.length === 0)
return value.every((id: string) => typeof id === 'string') 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'); throw new Error('secretIds must be a string or an array of strings');
}) })
.not() .not()
.isEmpty(), .isEmpty(),
validateRequest, validateRequest,
requireAuth({ requireAuth({
acceptedAuthModes: [AUTH_MODE_JWT, AUTH_MODE_API_KEY, AUTH_MODE_SERVICE_TOKEN] acceptedAuthModes: [
}), AUTH_MODE_JWT,
requireSecretsAuth({ AUTH_MODE_API_KEY,
acceptedRoles: [ADMIN, MEMBER], AUTH_MODE_SERVICE_TOKEN,
requiredPermissions: [PERMISSION_WRITE_SECRETS] ],
}), }),
secretsController.deleteSecrets requireSecretsAuth({
acceptedRoles: [ADMIN, MEMBER],
requiredPermissions: [PERMISSION_WRITE_SECRETS],
}),
secretsController.deleteSecrets
); );
export default router; export default router;
+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;
};
+31 -38
View File
@@ -1,53 +1,46 @@
import { Types } from 'mongoose'; import { Types } from "mongoose";
import { Assign, Omit } from 'utility-types'; import { Assign, Omit } from "utility-types";
import { ISecret } from '../../models'; import { ISecret } from "../../models";
import { mongo } from 'mongoose'; import { mongo } from "mongoose";
// Everything is required, except the omitted types // 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 // 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 // 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 // Everything is required, except the omitted types
export type SanitizedSecretForCreate = Omit<ISecret, "version" | "_id">; export type SanitizedSecretForCreate = Omit<ISecret, "version" | "_id">;
export interface BatchSecretRequest { export interface BatchSecretRequest {
id: string; id: string;
method: 'POST' | 'PATCH' | 'DELETE'; method: "POST" | "PATCH" | "DELETE";
secret: Secret; secret: Secret;
} }
export interface BatchSecret { export interface BatchSecret {
_id: string; _id: string;
type: 'shared' | 'personal', type: "shared" | "personal";
secretBlindIndex: string; secretBlindIndex: string;
secretKeyCiphertext: string; secretKeyCiphertext: string;
secretKeyIV: string; secretKeyIV: string;
secretKeyTag: string; secretKeyTag: string;
secretValueCiphertext: string; secretValueCiphertext: string;
secretValueIV: string; secretValueIV: string;
secretValueTag: string; secretValueTag: string;
secretCommentCiphertext: string; secretCommentCiphertext: string;
secretCommentIV: string; secretCommentIV: string;
secretCommentTag: string; secretCommentTag: string;
tags: 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) => { // export const getFolderPath = async (folderId: string) => {
let currentFolder = await Folder.findById(folderId); // let currentFolder = await Folder.findById(folderId);
const pathSegments = []; // const pathSegments = [];
while (currentFolder) { // while (currentFolder) {
pathSegments.unshift(currentFolder.name); // pathSegments.unshift(currentFolder.name);
currentFolder = currentFolder.parent ? await Folder.findById(currentFolder.parent) : null; // 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. // 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 workspaceId - The ID of the workspace to search in.
@param environment - The environment to search in. // @param environment - The environment to search in.
@param secretPath - The secret path to search for. // @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. // @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. // @throws Error if the specified secret path is not found.
*/ // */
export const getFolderIdFromPath = async (workspaceId: string, environment: string, secretPath: string) => { // export const getFolderIdFromPath = async (workspaceId: string, environment: string, secretPath: string) => {
const secretPathParts = secretPath.split("/").filter(path => path != "") // const secretPathParts = secretPath.split("/").filter(path => path != "")
if (secretPathParts.length <= 1) { // if (secretPathParts.length <= 1) {
return undefined // root folder, so no folder id // return undefined // root folder, so no folder id
} // }
const folderId = await Folder.find({ path: secretPath, workspace: workspaceId, environment: environment }) // const folderId = await Folder.find({ path: secretPath, workspace: workspaceId, environment: environment })
if (!folderId) { // if (!folderId) {
throw Error("Secret path not found") // throw Error("Secret path not found")
} // }
return folderId // return folderId
} // }
/** // /**
* Cleans up a path by removing empty parts, duplicate slashes, // * Cleans up a path by removing empty parts, duplicate slashes,
* and ensuring it starts with ROOT_FOLDER_PATH. // * and ensuring it starts with ROOT_FOLDER_PATH.
* @param path - The input path to clean up. // * @param path - The input path to clean up.
* @returns The cleaned-up path string. // * @returns The cleaned-up path string.
*/ // */
export const normalizePath = (path: string) => { // export const normalizePath = (path: string) => {
if (path == undefined || path == "" || path == ROOT_FOLDER_PATH) { // if (path == undefined || path == "" || path == ROOT_FOLDER_PATH) {
return ROOT_FOLDER_PATH // return ROOT_FOLDER_PATH
} // }
const pathParts = path.split("/").filter(part => part != "") // const pathParts = path.split("/").filter(part => part != "")
const cleanPathString = ROOT_FOLDER_PATH + pathParts.join("/") // const cleanPathString = ROOT_FOLDER_PATH + pathParts.join("/")
return cleanPathString // return cleanPathString
} // }
export const getFoldersInDirectory = async (workspaceId: string, environment: string, pathString: string) => { // export const getFoldersInDirectory = async (workspaceId: string, environment: string, pathString: string) => {
const normalizedPath = normalizePath(pathString) // const normalizedPath = normalizePath(pathString)
const foldersInDirectory = await Folder.find({ // const foldersInDirectory = await Folder.find({
workspace: workspaceId, // workspace: workspaceId,
environment: environment, // environment: environment,
parentPath: normalizedPath, // parentPath: normalizedPath,
}); // });
return foldersInDirectory; // return foldersInDirectory;
} // }
/** // /**
* Returns the parent path of the given path. // * Returns the parent path of the given path.
* @param path - The input path. // * @param path - The input path.
* @returns The parent path string. // * @returns The parent path string.
*/ // */
export const getParentPath = (path: string) => { // export const getParentPath = (path: string) => {
const normalizedPath = normalizePath(path); // const normalizedPath = normalizePath(path);
const folderParts = normalizedPath.split('/').filter(part => part !== ''); // const folderParts = normalizedPath.split('/').filter(part => part !== '');
let folderParent = ROOT_FOLDER_PATH; // let folderParent = ROOT_FOLDER_PATH;
if (folderParts.length > 1) { // if (folderParts.length > 1) {
folderParent = ROOT_FOLDER_PATH + folderParts.slice(0, folderParts.length - 1).join('/'); // folderParent = ROOT_FOLDER_PATH + folderParts.slice(0, folderParts.length - 1).join('/');
} // }
return folderParent; // return folderParent;
} // }
export const validateFolderName = (folderName: string) => { // export const validateFolderName = (folderName: string) => {
const validNameRegex = /^[a-zA-Z0-9-_]+$/; // const validNameRegex = /^[a-zA-Z0-9-_]+$/;
return validNameRegex.test(folderName); // return validNameRegex.test(folderName);
} // }
+69
View File
@@ -0,0 +1,69 @@
/*
Original work Copyright (c) 2016, Nikolay Nemshilov <[email protected]>
Modified work Copyright (c) 2016, David Banham <[email protected]>
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
};
+323 -274
View File
@@ -1,211 +1,209 @@
import crypto from 'crypto'; import crypto from "crypto";
import { encryptSymmetric128BitHexKeyUTF8 } from '../crypto'; import { Types } from "mongoose";
import { EESecretService } from '../../ee/services'; import { encryptSymmetric128BitHexKeyUTF8 } from "../crypto";
import { SecretVersion } from '../../ee/models'; import { EESecretService } from "../../ee/services";
import { ISecretVersion, SecretSnapshot, SecretVersion } from "../../ee/models";
import { import {
Secret, Secret,
ISecret, ISecret,
SecretBlindIndexData, SecretBlindIndexData,
Workspace, Workspace,
Bot, Bot,
BackupPrivateKey, BackupPrivateKey,
IntegrationAuth, IntegrationAuth,
} from '../../models'; } from "../../models";
import { generateKeyPair } from "../../utils/crypto";
import { client, getEncryptionKey, getRootEncryptionKey } from "../../config";
import { import {
generateKeyPair ALGORITHM_AES_256_GCM,
} from '../../utils/crypto'; ENCODING_SCHEME_UTF8,
import { ENCODING_SCHEME_BASE64,
client, } from "../../variables";
getEncryptionKey, import { InternalServerError } from "../errors";
getRootEncryptionKey
} from '../../config';
import {
ALGORITHM_AES_256_GCM,
ENCODING_SCHEME_UTF8,
ENCODING_SCHEME_BASE64
} from '../../variables';
import { InternalServerError } from '../errors';
/** /**
* Backfill secrets to ensure that they're all versioned and have * Backfill secrets to ensure that they're all versioned and have
* corresponding secret versions * corresponding secret versions
*/ */
export const backfillSecretVersions = async () => { export const backfillSecretVersions = async () => {
await Secret.updateMany( await Secret.updateMany(
{ version: { $exists: false } }, { version: { $exists: false } },
{ $set: { version: 1 } } { $set: { version: 1 } }
); );
const unversionedSecrets: ISecret[] = await Secret.aggregate([ const unversionedSecrets: ISecret[] = await Secret.aggregate([
{ {
$lookup: { $lookup: {
from: "secretversions", from: "secretversions",
localField: "_id", localField: "_id",
foreignField: "secret", foreignField: "secret",
as: "versions", as: "versions",
}, },
}, },
{ {
$match: { $match: {
versions: { $size: 0 }, versions: { $size: 0 },
}, },
}, },
]); ]);
if (unversionedSecrets.length > 0) { if (unversionedSecrets.length > 0) {
await EESecretService.addSecretVersions({ await EESecretService.addSecretVersions({
secretVersions: unversionedSecrets.map( secretVersions: unversionedSecrets.map(
(s, idx) => (s, idx) =>
new SecretVersion({ new SecretVersion({
...s, ...s,
secret: s._id, secret: s._id,
version: s.version ? s.version : 1, version: s.version ? s.version : 1,
isDeleted: false, isDeleted: false,
workspace: s.workspace, workspace: s.workspace,
environment: s.environment, environment: s.environment,
algorithm: ALGORITHM_AES_256_GCM, algorithm: ALGORITHM_AES_256_GCM,
keyEncoding: ENCODING_SCHEME_UTF8 keyEncoding: ENCODING_SCHEME_UTF8,
}) })
), ),
}); });
} }
} };
/** /**
* Backfill workspace bots to ensure that every workspace has a bot * Backfill workspace bots to ensure that every workspace has a bot
*/ */
export const backfillBots = async () => { export const backfillBots = async () => {
const encryptionKey = await getEncryptionKey(); const encryptionKey = await getEncryptionKey();
const rootEncryptionKey = await getRootEncryptionKey(); const rootEncryptionKey = await getRootEncryptionKey();
const workspaceIdsWithBot = await Bot.distinct('workspace'); const workspaceIdsWithBot = await Bot.distinct("workspace");
const workspaceIdsToAddBot = await Workspace.distinct('_id', { const workspaceIdsToAddBot = await Workspace.distinct("_id", {
_id: { _id: {
$nin: workspaceIdsWithBot $nin: workspaceIdsWithBot,
} },
}); });
if (workspaceIdsToAddBot.length === 0) return; if (workspaceIdsToAddBot.length === 0) return;
const botsToInsert = await Promise.all( const botsToInsert = await Promise.all(
workspaceIdsToAddBot.map(async (workspaceToAddBot) => { workspaceIdsToAddBot.map(async (workspaceToAddBot) => {
const { publicKey, privateKey } = generateKeyPair(); const { publicKey, privateKey } = generateKeyPair();
if (rootEncryptionKey) { if (rootEncryptionKey) {
const { const {
ciphertext: encryptedPrivateKey, ciphertext: encryptedPrivateKey,
iv, iv,
tag tag,
} = client.encryptSymmetric(privateKey, rootEncryptionKey); } = client.encryptSymmetric(privateKey, rootEncryptionKey);
return new Bot({ return new Bot({
name: 'Infisical Bot', name: "Infisical Bot",
workspace: workspaceToAddBot, workspace: workspaceToAddBot,
isActive: false, isActive: false,
publicKey, publicKey,
encryptedPrivateKey, encryptedPrivateKey,
iv, iv,
tag, tag,
algorithm: ALGORITHM_AES_256_GCM, algorithm: ALGORITHM_AES_256_GCM,
keyEncoding: ENCODING_SCHEME_BASE64 keyEncoding: ENCODING_SCHEME_BASE64,
}); });
} else if (encryptionKey) { } else if (encryptionKey) {
const { const {
ciphertext: encryptedPrivateKey, ciphertext: encryptedPrivateKey,
iv, iv,
tag tag,
} = encryptSymmetric128BitHexKeyUTF8({ } = encryptSymmetric128BitHexKeyUTF8({
plaintext: privateKey, plaintext: privateKey,
key: encryptionKey key: encryptionKey,
}); });
return new Bot({ return new Bot({
name: 'Infisical Bot', name: "Infisical Bot",
workspace: workspaceToAddBot, workspace: workspaceToAddBot,
isActive: false, isActive: false,
publicKey, publicKey,
encryptedPrivateKey, encryptedPrivateKey,
iv, iv,
tag, tag,
algorithm: ALGORITHM_AES_256_GCM, algorithm: ALGORITHM_AES_256_GCM,
keyEncoding: ENCODING_SCHEME_UTF8 keyEncoding: ENCODING_SCHEME_UTF8,
}); });
} }
throw InternalServerError({ 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); await Bot.insertMany(botsToInsert);
} };
/** /**
* Backfill secret blind index data to ensure that every workspace * Backfill secret blind index data to ensure that every workspace
* has a secret blind index data * has a secret blind index data
*/ */
export const backfillSecretBlindIndexData = async () => { export const backfillSecretBlindIndexData = async () => {
const encryptionKey = await getEncryptionKey();
const rootEncryptionKey = await getRootEncryptionKey();
const encryptionKey = await getEncryptionKey(); const workspaceIdsBlindIndexed = await SecretBlindIndexData.distinct(
const rootEncryptionKey = await getRootEncryptionKey(); "workspace"
);
const workspaceIdsToBlindIndex = await Workspace.distinct("_id", {
_id: {
$nin: workspaceIdsBlindIndexed,
},
});
const workspaceIdsBlindIndexed = await SecretBlindIndexData.distinct('workspace'); if (workspaceIdsToBlindIndex.length === 0) return;
const workspaceIdsToBlindIndex = await Workspace.distinct('_id', {
_id: {
$nin: workspaceIdsBlindIndexed
}
});
if (workspaceIdsToBlindIndex.length === 0) return; const secretBlindIndexDataToInsert = await Promise.all(
workspaceIdsToBlindIndex.map(async (workspaceToBlindIndex) => {
const salt = crypto.randomBytes(16).toString("base64");
const secretBlindIndexDataToInsert = await Promise.all( if (rootEncryptionKey) {
workspaceIdsToBlindIndex.map(async (workspaceToBlindIndex) => { const {
const salt = crypto.randomBytes(16).toString('base64'); ciphertext: encryptedSaltCiphertext,
iv: saltIV,
tag: saltTag,
} = client.encryptSymmetric(salt, rootEncryptionKey);
if (rootEncryptionKey) { return new SecretBlindIndexData({
const { workspace: workspaceToBlindIndex,
ciphertext: encryptedSaltCiphertext, encryptedSaltCiphertext,
iv: saltIV, saltIV,
tag: saltTag saltTag,
} = client.encryptSymmetric(salt, rootEncryptionKey) algorithm: ALGORITHM_AES_256_GCM,
keyEncoding: ENCODING_SCHEME_BASE64,
});
} else if (encryptionKey) {
const {
ciphertext: encryptedSaltCiphertext,
iv: saltIV,
tag: saltTag,
} = encryptSymmetric128BitHexKeyUTF8({
plaintext: salt,
key: encryptionKey,
});
return new SecretBlindIndexData({ return new SecretBlindIndexData({
workspace: workspaceToBlindIndex, workspace: workspaceToBlindIndex,
encryptedSaltCiphertext, encryptedSaltCiphertext,
saltIV, saltIV,
saltTag, saltTag,
algorithm: ALGORITHM_AES_256_GCM, algorithm: ALGORITHM_AES_256_GCM,
keyEncoding: ENCODING_SCHEME_BASE64 keyEncoding: ENCODING_SCHEME_UTF8,
}); });
} else if (encryptionKey) { }
const {
ciphertext: encryptedSaltCiphertext,
iv: saltIV,
tag: saltTag
} = encryptSymmetric128BitHexKeyUTF8({
plaintext: salt,
key: encryptionKey
});
return new SecretBlindIndexData({ throw InternalServerError({
workspace: workspaceToBlindIndex, message:
encryptedSaltCiphertext, "Failed to backfill secret blind index data due to missing encryption key",
saltIV, });
saltTag, })
algorithm: ALGORITHM_AES_256_GCM, );
keyEncoding: ENCODING_SCHEME_UTF8
});
}
throw InternalServerError({ SecretBlindIndexData.insertMany(secretBlindIndexDataToInsert);
message: 'Failed to backfill secret blind index data due to missing encryption key' };
});
})
);
SecretBlindIndexData.insertMany(secretBlindIndexDataToInsert);
}
/** /**
* Backfill Secret, SecretVersion, SecretBlindIndexData, Bot, * Backfill Secret, SecretVersion, SecretBlindIndexData, Bot,
@@ -213,112 +211,163 @@ export const backfillSecretBlindIndexData = async () => {
* they all have encryption metadata documented * they all have encryption metadata documented
*/ */
export const backfillEncryptionMetadata = async () => { export const backfillEncryptionMetadata = async () => {
// backfill secret encryption metadata
await Secret.updateMany(
{
algorithm: {
$exists: false,
},
keyEncoding: {
$exists: false,
},
},
{
$set: {
algorithm: ALGORITHM_AES_256_GCM,
keyEncoding: ENCODING_SCHEME_UTF8,
},
}
);
// backfill secret encryption metadata // backfill secret version encryption metadata
await Secret.updateMany( await SecretVersion.updateMany(
{ {
algorithm: { algorithm: {
$exists: false $exists: false,
}, },
keyEncoding: { keyEncoding: {
$exists: false $exists: false,
} },
}, },
{ {
$set: { $set: {
algorithm: ALGORITHM_AES_256_GCM, algorithm: ALGORITHM_AES_256_GCM,
keyEncoding: ENCODING_SCHEME_UTF8 keyEncoding: ENCODING_SCHEME_UTF8,
} },
} }
); );
// backfill secret version encryption metadata // backfill secret blind index encryption metadata
await SecretVersion.updateMany( await SecretBlindIndexData.updateMany(
{ {
algorithm: { algorithm: {
$exists: false $exists: false,
}, },
keyEncoding: { keyEncoding: {
$exists: false $exists: false,
} },
}, },
{ {
$set: { $set: {
algorithm: ALGORITHM_AES_256_GCM, algorithm: ALGORITHM_AES_256_GCM,
keyEncoding: ENCODING_SCHEME_UTF8 keyEncoding: ENCODING_SCHEME_UTF8,
} },
} }
); );
// backfill secret blind index encryption metadata // backfill bot encryption metadata
await SecretBlindIndexData.updateMany( await Bot.updateMany(
{ {
algorithm: { algorithm: {
$exists: false $exists: false,
}, },
keyEncoding: { keyEncoding: {
$exists: false $exists: false,
} },
}, },
{ {
$set: { $set: {
algorithm: ALGORITHM_AES_256_GCM, algorithm: ALGORITHM_AES_256_GCM,
keyEncoding: ENCODING_SCHEME_UTF8 keyEncoding: ENCODING_SCHEME_UTF8,
} },
} }
); );
// backfill bot encryption metadata // backfill backup private key encryption metadata
await Bot.updateMany( await BackupPrivateKey.updateMany(
{ {
algorithm: { algorithm: {
$exists: false $exists: false,
}, },
keyEncoding: { keyEncoding: {
$exists: false $exists: false,
} },
}, },
{ {
$set: { $set: {
algorithm: ALGORITHM_AES_256_GCM, algorithm: ALGORITHM_AES_256_GCM,
keyEncoding: ENCODING_SCHEME_UTF8 keyEncoding: ENCODING_SCHEME_UTF8,
} },
} }
); );
// backfill backup private key encryption metadata // backfill integration auth encryption metadata
await BackupPrivateKey.updateMany( await IntegrationAuth.updateMany(
{ {
algorithm: { algorithm: {
$exists: false $exists: false,
}, },
keyEncoding: { keyEncoding: {
$exists: false $exists: false,
} },
}, },
{ {
$set: { $set: {
algorithm: ALGORITHM_AES_256_GCM, algorithm: ALGORITHM_AES_256_GCM,
keyEncoding: ENCODING_SCHEME_UTF8 keyEncoding: ENCODING_SCHEME_UTF8,
} },
} }
); );
};
// backfill integration auth encryption metadata export const backfillSecretFolders = async () => {
await IntegrationAuth.updateMany( await Secret.updateMany(
{ {
algorithm: { folder: {
$exists: false $exists: false,
}, },
keyEncoding: { },
$exists: false {
} $set: {
}, folder: "root",
{ },
$set: { }
algorithm: ALGORITHM_AES_256_GCM, );
keyEncoding: ENCODING_SCHEME_UTF8
} 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);
}
};
+60 -50
View File
@@ -3,26 +3,27 @@ import { DatabaseService, TelemetryService } from '../../services';
import { setTransporter } from '../../helpers/nodemailer'; import { setTransporter } from '../../helpers/nodemailer';
import { EELicenseService } from '../../ee/services'; import { EELicenseService } from '../../ee/services';
import { initSmtp } from '../../services/smtp'; import { initSmtp } from '../../services/smtp';
import { createTestUserForDevelopment } from '../addDevelopmentUser' import { createTestUserForDevelopment } from '../addDevelopmentUser';
// eslint-disable-next-line @typescript-eslint/no-var-requires
const { patchRouterParam } = require('../patchAsyncRoutes');
import { validateEncryptionKeysConfig } from './validateConfig';
import { import {
validateEncryptionKeysConfig backfillSecretVersions,
} from './validateConfig'; backfillBots,
import { backfillSecretBlindIndexData,
backfillSecretVersions, backfillEncryptionMetadata,
backfillBots, backfillSecretFolders,
backfillSecretBlindIndexData,
backfillEncryptionMetadata
} from './backfillData'; } from './backfillData';
import { import {
reencryptBotPrivateKeys, reencryptBotPrivateKeys,
reencryptSecretBlindIndexDataSalts reencryptSecretBlindIndexDataSalts,
} from './reencryptData'; } from './reencryptData';
import { import {
getNodeEnv, getNodeEnv,
getMongoURL, getMongoURL,
getSentryDSN, getSentryDSN,
getClientSecretGoogle, getClientSecretGoogle,
getClientIdGoogle getClientIdGoogle,
} from '../../config'; } from '../../config';
import { initializePassport } from '../auth'; import { initializePassport } from '../auth';
@@ -37,49 +38,58 @@ import { initializePassport } from '../auth';
* - Re-encrypting data * - Re-encrypting data
*/ */
export const setup = async () => { export const setup = async () => {
await validateEncryptionKeysConfig(); patchRouterParam();
await TelemetryService.logTelemetryMessage(); await validateEncryptionKeysConfig();
await TelemetryService.logTelemetryMessage();
// initializing SMTP configuration // initializing SMTP configuration
setTransporter(await initSmtp()); setTransporter(await initSmtp());
// initializing global feature set // initializing global feature set
await EELicenseService.initGlobalFeatureSet(); await EELicenseService.initGlobalFeatureSet();
// initializing the database connection // initializing the database connection
await DatabaseService.initDatabase(await getMongoURL()); await DatabaseService.initDatabase(await getMongoURL());
const googleClientSecret: string = await getClientSecretGoogle(); const googleClientSecret: string = await getClientSecretGoogle();
const googleClientId: string = await getClientIdGoogle(); const googleClientId: string = await getClientIdGoogle();
if (googleClientId && googleClientSecret) { if (googleClientId && googleClientSecret) {
await initializePassport(); await initializePassport();
} }
/** // re-encrypt any data previously encrypted under server hex 128-bit ENCRYPTION_KEY
* NOTE: the order in this setup function is critical. // to base64 256-bit ROOT_ENCRYPTION_KEY
* It is important to backfill data before performing any re-encryption functionality. // await reencryptBotPrivateKeys();
*/ // await reencryptSecretBlindIndexDataSalts();
// backfilling data to catch up with new collections and updated fields // initializing the database connection
await backfillSecretVersions(); await DatabaseService.initDatabase(await getMongoURL());
await backfillBots();
await backfillSecretBlindIndexData();
await backfillEncryptionMetadata();
// re-encrypt any data previously encrypted under server hex 128-bit ENCRYPTION_KEY /**
// to base64 256-bit ROOT_ENCRYPTION_KEY * NOTE: the order in this setup function is critical.
// await reencryptBotPrivateKeys(); * It is important to backfill data before performing any re-encryption functionality.
// await reencryptSecretBlindIndexDataSalts(); */
// initializing Sentry // backfilling data to catch up with new collections and updated fields
Sentry.init({ await backfillSecretVersions();
dsn: await getSentryDSN(), await backfillBots();
tracesSampleRate: 1.0, await backfillSecretBlindIndexData();
debug: (await getNodeEnv()) === 'production' ? false : true, await backfillEncryptionMetadata();
environment: (await getNodeEnv()) await backfillSecretFolders();
});
await createTestUserForDevelopment(); // 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 Sentry
Sentry.init({
dsn: await getSentryDSN(),
tracesSampleRate: 1.0,
debug: (await getNodeEnv()) === 'production' ? false : true,
environment: await getNodeEnv(),
});
await createTestUserForDevelopment();
};
@@ -7,6 +7,17 @@ import { useOrganization, useWorkspace } from '@app/context';
import { Select, SelectItem, Tooltip } from '../v2'; 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 // TODO: make links clickable and clean up
/** /**
@@ -29,15 +40,10 @@ export default function NavHeader({
isOrganizationRelated, isOrganizationRelated,
currentEnv, currentEnv,
userAvailableEnvs, userAvailableEnvs,
onEnvChange onEnvChange,
}: { folders,
pageName: string; isFolderMode
isProjectRelated?: boolean; }: Props): JSX.Element {
isOrganizationRelated?: boolean;
currentEnv?: string;
userAvailableEnvs?: any[];
onEnvChange?: (slug: string) => void;
}): JSX.Element {
const { currentWorkspace } = useWorkspace(); const { currentWorkspace } = useWorkspace();
const { currentOrg } = useOrganization(); const { currentOrg } = useOrganization();
const router = useRouter(); const router = useRouter();
@@ -95,6 +101,26 @@ export default function NavHeader({
</div> </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> </div>
); );
} }
@@ -1,4 +1,5 @@
import { ReactNode } from 'react'; import { ReactNode } from 'react';
import { SizeProp } from '@fortawesome/fontawesome-svg-core';
import { faCubesStacked, IconDefinition } from '@fortawesome/free-solid-svg-icons'; import { faCubesStacked, IconDefinition } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { twMerge } from 'tailwind-merge'; import { twMerge } from 'tailwind-merge';
@@ -8,13 +9,25 @@ type Props = {
className?: string; className?: string;
children?: ReactNode; children?: ReactNode;
icon?: IconDefinition; icon?: IconDefinition;
iconSize?: SizeProp;
}; };
export const EmptyState = ({ title, className, children, icon = faCubesStacked }: Props) => ( export const EmptyState = ({
<div className={twMerge('flex w-full bg-bunker-700 flex-col items-center px-2 pt-6 text-bunker-300', className)}> title,
<FontAwesomeIcon icon={icon} size="2x" className='mr-4' /> className,
<div className='flex flex-row items-center py-4'> children,
<div className="text-bunker-300 text-sm">{title}</div> 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>{children}</div>
</div> </div>
</div> </div>
@@ -35,7 +35,7 @@ export const Tooltip = ({
sideOffset={5} sideOffset={5}
{...props} {...props}
className={twMerge( 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=top]:animate-slideDownAndFade
data-[state=delayed-open]:data-[side=right]:animate-slideLeftAndFade data-[state=delayed-open]:data-[side=right]:animate-slideLeftAndFade
data-[state=delayed-open]:data-[side=left]:animate-slideRightAndFade data-[state=delayed-open]:data-[side=left]:animate-slideRightAndFade
+1
View File
@@ -2,6 +2,7 @@ export * from './auth';
export * from './incidentContacts'; export * from './incidentContacts';
export * from './keys'; export * from './keys';
export * from './organization'; export * from './organization';
export * from './secretFolders';
export * from './secrets'; export * from './secrets';
export * from './secretSnapshots'; export * from './secretSnapshots';
export * from './serviceAccounts'; 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'; } from './types';
export const secretSnapshotKeys = { 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, 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[] }>( const res = await apiRequest.get<{ secretSnapshots: TWorkspaceSecretSnapshot[] }>(
`/api/v1/workspace/${workspaceId}/secret-snapshots`, `/api/v1/workspace/${workspaceId}/secret-snapshots`,
{ {
params: { params: {
limit, limit,
offset offset,
environment,
folderId
} }
} }
); );
@@ -38,9 +51,16 @@ const fetchWorkspaceSecretSnaphots = async (workspaceId: string, limit = 10, off
export const useGetWorkspaceSecretSnapshots = (dto: GetWorkspaceSecretSnapshotsDTO) => export const useGetWorkspaceSecretSnapshots = (dto: GetWorkspaceSecretSnapshotsDTO) =>
useInfiniteQuery({ useInfiniteQuery({
enabled: Boolean(dto.workspaceId), enabled: Boolean(dto.workspaceId && dto.environment),
queryKey: secretSnapshotKeys.list(dto.workspaceId), queryKey: secretSnapshotKeys.list(dto.workspaceId, dto.environment, dto?.folder),
queryFn: ({ pageParam }) => fetchWorkspaceSecretSnaphots(dto.workspaceId, dto.limit, pageParam), queryFn: ({ pageParam }) =>
fetchWorkspaceSecretSnaphots(
dto.workspaceId,
dto.environment,
dto?.folder,
dto.limit,
pageParam
),
getNextPageParam: (lastPage, pages) => getNextPageParam: (lastPage, pages) =>
lastPage.length !== 0 ? pages.length * dto.limit : undefined 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 }>( 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; return res.data.count;
}; };
export const useGetWsSnapshotCount = (workspaceId: string) => export const useGetWsSnapshotCount = (workspaceId: string, env: string, folderId?: string) =>
useQuery({ useQuery({
enabled: Boolean(workspaceId), enabled: Boolean(workspaceId && env),
queryKey: secretSnapshotKeys.count(workspaceId), queryKey: secretSnapshotKeys.count(workspaceId, env, folderId),
queryFn: () => fetchWorkspaceSecretSnaphotCount(workspaceId) queryFn: () => fetchWorkspaceSecretSnaphotCount(workspaceId, env, folderId)
}); });
export const usePerformSecretRollback = () => { export const usePerformSecretRollback = () => {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation<{}, {}, TSecretRollbackDTO>({ return useMutation<{}, {}, TSecretRollbackDTO>({
mutationFn: async (dto) => { mutationFn: async ({ workspaceId, ...dto }) => {
const { data } = await apiRequest.post( const { data } = await apiRequest.post(
`/api/v1/workspace/${dto.workspaceId}/secret-snapshots/rollback`, `/api/v1/workspace/${workspaceId}/secret-snapshots/rollback`,
{ dto
version: dto.version
}
); );
return data; return data;
}, },
onSuccess: (_, dto) => { onSuccess: (_, { workspaceId, environment, folderId }) => {
queryClient.invalidateQueries(secretSnapshotKeys.list(dto.workspaceId)); queryClient.invalidateQueries([{ workspaceId, environment }, 'secrets']);
queryClient.invalidateQueries(secretSnapshotKeys.count(dto.workspaceId)); 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'> & { export type TSnapshotSecret = Omit<TWorkspaceSecretSnapshot, 'secretVersions'> & {
secretVersions: EncryptedSecretVersion[]; secretVersions: EncryptedSecretVersion[];
folderVersion: Array<{ name: string; id: string }>;
}; };
export type TSnapshotSecretProps = { export type TSnapshotSecretProps = {
@@ -24,9 +25,13 @@ export type TSnapshotSecretProps = {
export type GetWorkspaceSecretSnapshotsDTO = { export type GetWorkspaceSecretSnapshotsDTO = {
workspaceId: string; workspaceId: string;
limit: number; limit: number;
environment: string;
folder?: string;
}; };
export type TSecretRollbackDTO = { export type TSecretRollbackDTO = {
workspaceId: string; workspaceId: string;
version: number; version: number;
environment: string;
folderId?: string;
}; };
+24 -11
View File
@@ -19,19 +19,24 @@ import {
export const secretKeys = { export const secretKeys = {
// this is also used in secretSnapshot part // this is also used in secretSnapshot part
getProjectSecret: (workspaceId: string, env: string | string[]) => [ getProjectSecret: (workspaceId: string, env: string | string[], folderId?: string) => [
{ workspaceId, env }, { workspaceId, env, folderId },
'secrets' 'secrets'
], ],
getSecretVersion: (secretId: string) => [{ secretId }, 'secret-versions'] 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') { if (typeof env === 'string') {
const { data } = await apiRequest.get<{ secrets: EncryptedSecret[] }>('/api/v2/secrets', { const { data } = await apiRequest.get<{ secrets: EncryptedSecret[] }>('/api/v2/secrets', {
params: { params: {
environment: env, environment: env,
workspaceId workspaceId,
folderId: folderId || undefined
} }
}); });
return data.secrets; 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', { const { data } = await apiRequest.get<{ secrets: EncryptedSecret[] }>('/api/v2/secrets', {
params: { params: {
environment: envPoint, environment: envPoint,
workspaceId workspaceId,
folderId
} }
}); });
allEnvData = allEnvData.concat(data.secrets); allEnvData = allEnvData.concat(data.secrets);
@@ -63,13 +69,14 @@ export const useGetProjectSecrets = ({
workspaceId, workspaceId,
env, env,
decryptFileKey, decryptFileKey,
isPaused isPaused,
folderId
}: GetProjectSecretsDTO) => }: GetProjectSecretsDTO) =>
useQuery({ useQuery({
// wait for all values to be available // wait for all values to be available
enabled: Boolean(decryptFileKey && workspaceId && env) && !isPaused, enabled: Boolean(decryptFileKey && workspaceId && env) && !isPaused,
queryKey: secretKeys.getProjectSecret(workspaceId, env), queryKey: secretKeys.getProjectSecret(workspaceId, env, folderId),
queryFn: () => fetchProjectEncryptedSecrets(workspaceId, env), queryFn: () => fetchProjectEncryptedSecrets(workspaceId, env, folderId),
select: (data) => { select: (data) => {
const PRIVATE_KEY = localStorage.getItem('PRIVATE_KEY') as string; const PRIVATE_KEY = localStorage.getItem('PRIVATE_KEY') as string;
const latestKey = decryptFileKey; const latestKey = decryptFileKey;
@@ -283,9 +290,15 @@ export const useBatchSecretsOp = () => {
return data; return data;
}, },
onSuccess: (_, dto) => { onSuccess: (_, dto) => {
queryClient.invalidateQueries(secretKeys.getProjectSecret(dto.workspaceId, dto.environment)); queryClient.invalidateQueries(
queryClient.invalidateQueries(secretSnapshotKeys.list(dto.workspaceId)); secretKeys.getProjectSecret(dto.workspaceId, dto.environment, dto.folderId)
queryClient.invalidateQueries(secretSnapshotKeys.count(dto.workspaceId)); );
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 = { export type UpdateSecretArg = {
_id: string; _id: string;
folderId?: string;
type: 'shared' | 'personal'; type: 'shared' | 'personal';
secretName: string; secretName: string;
secretKeyCiphertext: string; secretKeyCiphertext: string;
@@ -81,6 +82,7 @@ export type DeleteSecretArg = { _id: string };
export type BatchSecretDTO = { export type BatchSecretDTO = {
workspaceId: string; workspaceId: string;
folderId: string;
environment: string; environment: string;
requests: Array< requests: Array<
| { method: 'POST'; secret: CreateSecretArg } | { method: 'POST'; secret: CreateSecretArg }
@@ -93,6 +95,7 @@ export type GetProjectSecretsDTO = {
workspaceId: string; workspaceId: string;
env: string | string[]; env: string | string[];
decryptFileKey: UserWsKeyPair; decryptFileKey: UserWsKeyPair;
folderId?: string;
isPaused?: boolean; isPaused?: boolean;
onSuccess?: (data: DecryptedSecret[]) => void; onSuccess?: (data: DecryptedSecret[]) => void;
}; };
@@ -10,6 +10,7 @@ import {
faDownload, faDownload,
faEye, faEye,
faEyeSlash, faEyeSlash,
faFolderPlus,
faMagnifyingGlass, faMagnifyingGlass,
faPlus faPlus
} from '@fortawesome/free-solid-svg-icons'; } 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 NavHeader from '@app/components/navigation/NavHeader';
import { import {
Button, Button,
DeleteActionModal,
IconButton, IconButton,
Input, Input,
Modal, Modal,
@@ -37,7 +39,10 @@ import { useWorkspace } from '@app/context';
import { useLeaveConfirm, usePopUp, useToggle } from '@app/hooks'; import { useLeaveConfirm, usePopUp, useToggle } from '@app/hooks';
import { import {
useBatchSecretsOp, useBatchSecretsOp,
useCreateFolder,
useCreateWsTag, useCreateWsTag,
useDeleteFolder,
useGetProjectFolders,
useGetProjectSecrets, useGetProjectSecrets,
useGetSecretVersion, useGetSecretVersion,
useGetSnapshotSecrets, useGetSnapshotSecrets,
@@ -48,13 +53,20 @@ import {
useGetWsSnapshotCount, useGetWsSnapshotCount,
useGetWsTags, useGetWsTags,
usePerformSecretRollback, usePerformSecretRollback,
useRegisterUserAction useRegisterUserAction,
useUpdateFolder
} from '@app/hooks/api'; } from '@app/hooks/api';
import { secretKeys } from '@app/hooks/api/secrets/queries'; import { secretKeys } from '@app/hooks/api/secrets/queries';
import { WorkspaceEnv } from '@app/hooks/api/types'; import { WorkspaceEnv } from '@app/hooks/api/types';
import { CompareSecret } from './components/CompareSecret'; import { CompareSecret } from './components/CompareSecret';
import { CreateTagModal } from './components/CreateTagModal'; import { CreateTagModal } from './components/CreateTagModal';
import {
FolderForm,
FolderSection,
TDeleteFolderForm,
TEditFolderForm
} from './components/FolderSection';
import { PitDrawer } from './components/PitDrawer'; import { PitDrawer } from './components/PitDrawer';
import { SecretDetailDrawer } from './components/SecretDetailDrawer'; import { SecretDetailDrawer } from './components/SecretDetailDrawer';
import { SecretDropzone } from './components/SecretDropzone'; import { SecretDropzone } from './components/SecretDropzone';
@@ -96,7 +108,9 @@ export const DashboardPage = ({ envFromTop }: { envFromTop: string }) => {
'addTag', 'addTag',
'secretSnapshots', 'secretSnapshots',
'uploadedSecOpts', 'uploadedSecOpts',
'compareSecrets' 'compareSecrets',
'folderForm',
'deleteFolder'
] as const); ] as const);
const [isSecretValueHidden, setIsSecretValueHidden] = useToggle(true); const [isSecretValueHidden, setIsSecretValueHidden] = useToggle(true);
const [searchFilter, setSearchFilter] = useState(''); const [searchFilter, setSearchFilter] = useState('');
@@ -106,6 +120,9 @@ export const DashboardPage = ({ envFromTop }: { envFromTop: string }) => {
const deletedSecretIds = useRef<string[]>([]); const deletedSecretIds = useRef<string[]>([]);
const { hasUnsavedChanges, setHasUnsavedChanges } = useLeaveConfirm({ initialValue: false }); const { hasUnsavedChanges, setHasUnsavedChanges } = useLeaveConfirm({ initialValue: false });
const folderId = router.query.folderId as string;
const isRollbackMode = Boolean(snapshotId);
const { currentWorkspace, isLoading } = useWorkspace(); const { currentWorkspace, isLoading } = useWorkspace();
const workspaceId = currentWorkspace?._id as string; const workspaceId = currentWorkspace?._id as string;
const { data: latestFileKey } = useGetUserWsKey(workspaceId); const { data: latestFileKey } = useGetUserWsKey(workspaceId);
@@ -142,7 +159,16 @@ export const DashboardPage = ({ envFromTop }: { envFromTop: string }) => {
workspaceId, workspaceId,
env: selectedEnv?.slug || '', env: selectedEnv?.slug || '',
decryptFileKey: latestFileKey!, 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 { const {
@@ -152,6 +178,8 @@ export const DashboardPage = ({ envFromTop }: { envFromTop: string }) => {
isFetchingNextPage isFetchingNextPage
} = useGetWorkspaceSecretSnapshots({ } = useGetWorkspaceSecretSnapshots({
workspaceId, workspaceId,
environment: selectedEnv?.slug || '',
folder: folderId,
limit: 10 limit: 10
}); });
@@ -165,8 +193,11 @@ export const DashboardPage = ({ envFromTop }: { envFromTop: string }) => {
decryptFileKey: latestFileKey! decryptFileKey: latestFileKey!
}); });
const { data: snapshotCount, isLoading: isLoadingSnapshotCount } = const { data: snapshotCount, isLoading: isLoadingSnapshotCount } = useGetWsSnapshotCount(
useGetWsSnapshotCount(workspaceId); workspaceId,
selectedEnv?.slug || '',
folderId
);
const { data: wsTags } = useGetWsTags(workspaceId); const { data: wsTags } = useGetWsTags(workspaceId);
// mutation calls // mutation calls
@@ -174,6 +205,9 @@ export const DashboardPage = ({ envFromTop }: { envFromTop: string }) => {
const { mutateAsync: performSecretRollback } = usePerformSecretRollback(); const { mutateAsync: performSecretRollback } = usePerformSecretRollback();
const { mutateAsync: registerUserAction } = useRegisterUserAction(); const { mutateAsync: registerUserAction } = useRegisterUserAction();
const { mutateAsync: createWsTag } = useCreateWsTag(); const { mutateAsync: createWsTag } = useCreateWsTag();
const { mutateAsync: createFolder } = useCreateFolder();
const { mutateAsync: updateFolder } = useUpdateFolder(folderId);
const { mutateAsync: deleteFolder } = useDeleteFolder(folderId);
const method = useForm<FormData>({ const method = useForm<FormData>({
// why any: well yup inferred ts expects other keys to defined as undefined // why any: well yup inferred ts expects other keys to defined as undefined
@@ -192,7 +226,6 @@ export const DashboardPage = ({ envFromTop }: { envFromTop: string }) => {
reset reset
} = method; } = method;
const { fields, prepend, append, remove } = useFieldArray({ control, name: 'secrets' }); const { fields, prepend, append, remove } = useFieldArray({ control, name: 'secrets' });
const isRollbackMode = Boolean(snapshotId);
const isReadOnly = selectedEnv?.isWriteDenied; const isReadOnly = selectedEnv?.isWriteDenied;
const isAddOnly = selectedEnv?.isReadDenied && !selectedEnv?.isWriteDenied; const isAddOnly = selectedEnv?.isReadDenied && !selectedEnv?.isWriteDenied;
const canDoRollback = !isReadOnly && !isAddOnly; const canDoRollback = !isReadOnly && !isAddOnly;
@@ -281,7 +314,9 @@ export const DashboardPage = ({ envFromTop }: { envFromTop: string }) => {
try { try {
await performSecretRollback({ await performSecretRollback({
workspaceId, workspaceId,
version: snapshotSecret.version version: snapshotSecret.version,
environment: selectedEnv?.slug || '',
folderId
}); });
setValue('isSnapshotMode', false); setValue('isSnapshotMode', false);
setSnaphotId(null); setSnaphotId(null);
@@ -326,6 +361,7 @@ export const DashboardPage = ({ envFromTop }: { envFromTop: string }) => {
await batchSecretOp({ await batchSecretOp({
requests: batchedSecret, requests: batchedSecret,
workspaceId, workspaceId,
folderId,
environment: selectedEnv?.slug environment: selectedEnv?.slug
}); });
createNotification({ createNotification({
@@ -356,9 +392,11 @@ export const DashboardPage = ({ envFromTop }: { envFromTop: string }) => {
} }
const env = wsEnv?.find((el) => el.slug === slug); const env = wsEnv?.find((el) => el.slug === slug);
if (env) setSelectedEnv(env); if (env) setSelectedEnv(env);
const query: Record<string, string> = { ...router.query, env: slug };
delete query.folderId;
router.push({ router.push({
pathname: router.pathname, 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) { if (isSecretsLoading || isEnvListLoading) {
return ( 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]"> <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( const userAvailableEnvs = wsEnv?.filter(
({ isReadDenied, isWriteDenied }) => !isReadDenied || !isWriteDenied ({ isReadDenied, isWriteDenied }) => !isReadDenied || !isWriteDenied
); );
return ( 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}> <FormProvider {...method}>
<form autoComplete="off"> <form autoComplete="off" className="h-full">
{/* breadcrumb row */} {/* breadcrumb row */}
<div className="relative right-6 mb-6 -top-2"> <div className="relative right-6 -top-2 mb-2">
<NavHeader <NavHeader
pageName={t('dashboard.title')} pageName={t('dashboard.title')}
currentEnv={ currentEnv={
userAvailableEnvs?.filter((envir) => envir.slug === envFromTop)[0].name || '' userAvailableEnvs?.filter((envir) => envir.slug === envFromTop)[0].name || ''
} }
isFolderMode
folders={folderData?.dir}
isProjectRelated isProjectRelated
userAvailableEnvs={userAvailableEnvs} userAvailableEnvs={userAvailableEnvs}
onEnvChange={onEnvChange} onEnvChange={onEnvChange}
/> />
</div> </div>
{/* This is only for rollbacks */} <div className="mb-4">
{isRollbackMode && <h6 className="text-2xl">{isRollbackMode ? 'Secret Snapshot' : ''}</h6>
<div className="flex items-center justify-between"> {isRollbackMode && Boolean(snapshotSecret) && (
<div className="flex items-center space-x-4"> <Tag colorSchema="green">
<h1 className="text-3xl font-semibold">Secret Snapshot</h1> {new Date(snapshotSecret?.createdAt || '').toLocaleString()}
{isRollbackMode && Boolean(snapshotSecret) && ( </Tag>
<Tag colorSchema="green"> )}
{new Date(snapshotSecret?.createdAt || '').toLocaleString()} </div>
</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 */} {/* Environment, search and other action row */}
<div className="mt-2 flex items-center space-x-2 justify-between"> <div className="flex items-center justify-between space-x-2">
<div className="flex-grow max-w-sm"> <div className="flex max-w-lg flex-grow space-x-2">
<Input <Input
className="h-[2.3rem] bg-mineshaft-800 focus:bg-mineshaft-700/80 duration-200 placeholder-mineshaft-50" className="h-[2.3rem] bg-mineshaft-800 placeholder-mineshaft-50 duration-200 focus:bg-mineshaft-700/80"
placeholder="Search keys..." placeholder="Search by folder name, key name, comment..."
value={searchFilter} value={searchFilter}
onChange={(e) => setSearchFilter(e.target.value)} onChange={(e) => setSearchFilter(e.target.value)}
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />} leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
@@ -500,8 +610,8 @@ export const DashboardPage = ({ envFromTop }: { envFromTop: string }) => {
</IconButton> </IconButton>
</Tooltip> </Tooltip>
</div> </div>
<div className='block xl:hidden'> <div className="block xl:hidden">
<Tooltip content='Point-in-time Recovery'> <Tooltip content="Point-in-time Recovery">
<IconButton <IconButton
ariaLabel="recovery" ariaLabel="recovery"
variant="outline_bg" variant="outline_bg"
@@ -511,7 +621,7 @@ export const DashboardPage = ({ envFromTop }: { envFromTop: string }) => {
</IconButton> </IconButton>
</Tooltip> </Tooltip>
</div> </div>
<div className='hidden xl:block'> <div className="hidden xl:block">
<Button <Button
variant="outline_bg" variant="outline_bg"
onClick={() => handlePopUpOpen('secretSnapshots')} onClick={() => handlePopUpOpen('secretSnapshots')}
@@ -525,8 +635,20 @@ export const DashboardPage = ({ envFromTop }: { envFromTop: string }) => {
</div> </div>
{!isReadOnly && !isRollbackMode && ( {!isReadOnly && !isRollbackMode && (
<> <>
<div className='block lg:hidden'> <div className="block lg:hidden">
<Tooltip content='Point-in-time Recovery'> <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 <IconButton
ariaLabel="recovery" ariaLabel="recovery"
variant="outline_bg" variant="outline_bg"
@@ -544,7 +666,19 @@ export const DashboardPage = ({ envFromTop }: { envFromTop: string }) => {
</IconButton> </IconButton>
</Tooltip> </Tooltip>
</div> </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 <Button
leftIcon={<FontAwesomeIcon icon={faPlus} />} leftIcon={<FontAwesomeIcon icon={faPlus} />}
onClick={() => { onClick={() => {
@@ -565,6 +699,19 @@ export const DashboardPage = ({ envFromTop }: { envFromTop: string }) => {
</div> </div>
</> </>
)} )}
{isRollbackMode && (
<Button
variant="star"
leftIcon={<FontAwesomeIcon icon={faArrowLeft} />}
onClick={() => {
setSnaphotId(null);
reset({ ...secrets, isSnapshotMode: false });
}}
className="h-10"
>
Go back
</Button>
)}
<Button <Button
isDisabled={isSubmitDisabled} isDisabled={isSubmitDisabled}
isLoading={isSubmitting} isLoading={isSubmitting}
@@ -580,15 +727,22 @@ export const DashboardPage = ({ envFromTop }: { envFromTop: string }) => {
</div> </div>
<div <div
className={`${ className={`${
isSecretEmpty ? 'flex flex-col items-center justify-center' : '' isEmptyPage ? '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`} } no-scrollbar::-webkit-scrollbar mt-3 h-3/4 overflow-x-hidden overflow-y-scroll no-scrollbar`}
ref={secretContainer} ref={secretContainer}
> >
{!isSecretEmpty && ( {!isEmptyPage && (
<TableContainer className="max-h-[calc(100%-40px)] no-scrollbar no-scrollbar::-webkit-scrollbar"> <TableContainer className="no-scrollbar::-webkit-scrollbar max-h-[calc(100%-120px)] no-scrollbar">
<table className="secret-table relative"> <table className="secret-table relative">
<SecretTableHeader sortDir={sortDir} onSort={onSortSecrets} /> <SecretTableHeader sortDir={sortDir} onSort={onSortSecrets} />
<tbody className="max-h-96 overflow-y-auto"> <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) => ( {fields.map(({ id, _id }, index) => (
<SecretInputRow <SecretInputRow
key={id} key={id}
@@ -609,7 +763,7 @@ export const DashboardPage = ({ envFromTop }: { envFromTop: string }) => {
<td colSpan={3} className="hover:bg-mineshaft-700"> <td colSpan={3} className="hover:bg-mineshaft-700">
<button <button
type="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} onClick={onAppendSecret}
> >
<FontAwesomeIcon icon={faPlus} /> <FontAwesomeIcon icon={faPlus} />
@@ -643,7 +797,7 @@ export const DashboardPage = ({ envFromTop }: { envFromTop: string }) => {
onEnvCompare={(key) => handlePopUpOpen('compareSecrets', key)} onEnvCompare={(key) => handlePopUpOpen('compareSecrets', key)}
/> />
<SecretDropzone <SecretDropzone
isSmaller={!isSecretEmpty} isSmaller={!isEmptyPage}
onParsedEnv={handleUploadedEnv} onParsedEnv={handleUploadedEnv}
onAddNewSecret={onAppendSecret} onAddNewSecret={onAppendSecret}
/> />
@@ -695,6 +849,26 @@ export const DashboardPage = ({ envFromTop }: { envFromTop: string }) => {
</div> </div>
</ModalContent> </ModalContent>
</Modal> </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 <Modal
isOpen={popUp?.compareSecrets?.isOpen} isOpen={popUp?.compareSecrets?.isOpen}
onOpenChange={(open) => handlePopUpToggle('compareSecrets', open)} 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) => { const syntaxHighlight = useCallback((val: string) => {
if (val?.length === 0) return <span className="font-sans text-bunker-400/80">EMPTY</span>; 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 ? ( 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)} {word.slice(0, 2)}
<span className="ph-no-capture text-yellow-200/80">{word.slice(2, word.length - 1)}</span> <span className="ph-no-capture text-yellow-200/80">{word.slice(2, word.length - 1)}</span>
{word.slice(word.length - 1, word.length) === '}' ? ( {word.slice(word.length - 1, word.length) === '}' ? (
@@ -40,7 +40,7 @@ export const MaskedInput = ({ isReadOnly, isSecretValueHidden, index, isOverridd
)} )}
</span> </span>
) : ( ) : (
<span key={`${word}_${index + 1}`} className="ph-no-capture"> <span key={`${word}_${i + 1}`} className="ph-no-capture">
{word} {word}
</span> </span>
) )
@@ -6,7 +6,6 @@ import {
faComment, faComment,
faEllipsis, faEllipsis,
faInfoCircle, faInfoCircle,
faKey,
faPlus, faPlus,
faTags, faTags,
faXmark faXmark
@@ -159,9 +158,8 @@ export const SecretInputRow = memo(
return ( return (
<tr className="group flex flex-row items-center" key={index}> <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"> <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> */} <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> </td>
<Controller <Controller
control={control} control={control}
@@ -200,7 +198,7 @@ export const SecretInputRow = memo(
</HoverCard> </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 <MaskedInput
isReadOnly={ isReadOnly={
isReadOnly || isRollbackMode || (isOverridden ? isAddOnly : shouldBeBlockedInAddOnly) isReadOnly || isRollbackMode || (isOverridden ? isAddOnly : shouldBeBlockedInAddOnly)
@@ -226,10 +224,10 @@ export const SecretInputRow = memo(
</Tag> </Tag>
))} ))}
{!(isReadOnly || isAddOnly || isRollbackMode) && ( {!(isReadOnly || isAddOnly || isRollbackMode) && (
<div className="overflow-hidden duration-0 ml-1"> <div className="duration-0 ml-1 overflow-hidden">
<Popover> <Popover>
<PopoverTrigger asChild> <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"> <Tooltip content="Add tags">
<IconButton <IconButton
variant="plain" variant="plain"
@@ -291,7 +289,7 @@ export const SecretInputRow = memo(
</div> </div>
)} )}
</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 && ( {!isAddOnly && (
<div> <div>
<Tooltip content="Override with a personal value"> <Tooltip content="Override with a personal value">
@@ -314,16 +312,14 @@ export const SecretInputRow = memo(
</div> </div>
)} )}
<Tooltip content="Comment"> <Tooltip content="Comment">
<div <div className={`mt-0.5 overflow-hidden `}>
className={`mt-0.5 overflow-hidden `}
>
<Popover> <Popover>
<PopoverTrigger asChild> <PopoverTrigger asChild>
<IconButton <IconButton
className={twMerge( className={twMerge(
'overflow-hidden p-0 w-7', 'w-7 overflow-hidden p-0',
'data-[state=open]:w-7 group-hover:w-7 w-0', 'w-0 group-hover:w-7 data-[state=open]:w-7',
hasComment ? 'text-primary w-7' : 'group-hover:w-7' hasComment ? 'w-7 text-primary' : 'group-hover:w-7'
)} )}
variant="plain" variant="plain"
size="md" size="md"
@@ -332,12 +328,13 @@ export const SecretInputRow = memo(
<FontAwesomeIcon icon={faComment} /> <FontAwesomeIcon icon={faComment} />
</IconButton> </IconButton>
</PopoverTrigger> </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"> <FormControl label="Comment" className="mb-0">
<TextArea <TextArea
isDisabled={ isDisabled={isReadOnly || isRollbackMode || shouldBeBlockedInAddOnly}
isReadOnly || isRollbackMode || shouldBeBlockedInAddOnly
}
className="border border-mineshaft-600 text-sm" className="border border-mineshaft-600 text-sm"
{...register(`secrets.${index}.comment`)} {...register(`secrets.${index}.comment`)}
rows={8} rows={8}
@@ -349,7 +346,7 @@ export const SecretInputRow = memo(
</div> </div>
</Tooltip> </Tooltip>
</div> </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 && ( {!isAddOnly && (
<div className="opacity-0 group-hover:opacity-100"> <div className="opacity-0 group-hover:opacity-100">
<Tooltip content="Settings"> <Tooltip content="Settings">
@@ -11,7 +11,7 @@ type Props = {
export const SecretTableHeader = ({ sortDir, onSort }: Props): JSX.Element => ( export const SecretTableHeader = ({ sortDir, onSort }: Props): JSX.Element => (
<thead className="sticky top-0 z-50 bg-mineshaft-800"> <thead className="sticky top-0 z-50 bg-mineshaft-800">
<tr className="top-0 flex flex-row"> <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> <div className="w-10 text-center text-xs text-transparent">{0}</div>
</td> </td>
<td className="flex items-center"> <td className="flex items-center">