diff --git a/backend/src/controllers/v1/secretImpsController.ts b/backend/src/controllers/v1/secretImpsController.ts index a16d666c4..956229b7c 100644 --- a/backend/src/controllers/v1/secretImpsController.ts +++ b/backend/src/controllers/v1/secretImpsController.ts @@ -2,7 +2,7 @@ import { Request, Response } from "express"; import { isValidScope } from "../../helpers"; import { Folder, IServiceTokenData, SecretImport, ServiceTokenData } from "../../models"; import { getAllImportedSecrets } from "../../services/SecretImportService"; -import { getFolderWithPathFromId } from "../../services/FolderService"; +import { getFolderByPath, getFolderWithPathFromId } from "../../services/FolderService"; import { BadRequestError, ResourceNotFoundError, @@ -95,37 +95,12 @@ export const createSecretImp = async (req: Request, res: Response) => { */ const { - body: { workspaceId, environment, folderId, secretImport } + body: { workspaceId, environment, directory, secretImport } } = await validateRequest(reqValidator.CreateSecretImportV1, req); - const folders = await Folder.findOne({ - workspace: workspaceId, - environment - }).lean(); - - if (!folders && folderId !== "root") { - throw ResourceNotFoundError({ - message: "Failed to find folder" - }); - } - - let secretPath = "/"; - if (folders) { - const { folderPath } = getFolderWithPathFromId(folders.nodes, folderId); - secretPath = folderPath; - } - if (req.authData.authPayload instanceof ServiceTokenData) { // root check - let isValidScopeAccess = isValidScope(req.authData.authPayload, environment, secretPath); - if (!isValidScopeAccess) { - throw UnauthorizedRequestError({ message: "Folder Permission Denied" }); - } - isValidScopeAccess = isValidScope( - req.authData.authPayload, - secretImport.environment, - secretImport.secretPath - ); + const isValidScopeAccess = isValidScope(req.authData.authPayload, environment, directory); if (!isValidScopeAccess) { throw UnauthorizedRequestError({ message: "Folder Permission Denied" }); } @@ -133,27 +108,31 @@ export const createSecretImp = async (req: Request, res: Response) => { const { permission } = await getUserProjectPermissions(req.user._id, workspaceId); ForbiddenError.from(permission).throwUnlessCan( ProjectPermissionActions.Create, - subject(ProjectPermissionSub.Secrets, { environment, secretPath }) - ); - ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionActions.Read, - subject(ProjectPermissionSub.Secrets, { - environment: secretImport.environment, - secretPath: secretImport.secretPath - }) + subject(ProjectPermissionSub.Secrets, { environment, secretPath: directory }) ); } + const folders = await Folder.findOne({ + workspace: workspaceId, + environment + }).lean(); + + if (!folders && directory !== "/") + throw ResourceNotFoundError({ message: "Failed to find folder" }); + + let folderId = "root"; + if (folders) { + const folder = getFolderByPath(folders.nodes, directory); + if (!folder) throw BadRequestError({ message: "Folder not found" }); + folderId = folder.id; + } + const importSecDoc = await SecretImport.findOne({ workspace: workspaceId, environment, folderId }); - const importToSecretPath = folders - ? getFolderWithPathFromId(folders.nodes, folderId).folderPath - : "/"; - if (!importSecDoc) { const doc = new SecretImport({ workspace: workspaceId, @@ -173,7 +152,7 @@ export const createSecretImp = async (req: Request, res: Response) => { importFromEnvironment: secretImport.environment, importFromSecretPath: secretImport.secretPath, importToEnvironment: environment, - importToSecretPath + importToSecretPath: directory } }, { @@ -206,7 +185,7 @@ export const createSecretImp = async (req: Request, res: Response) => { importFromEnvironment: secretImport.environment, importFromSecretPath: secretImport.secretPath, importToEnvironment: environment, - importToSecretPath + importToSecretPath: directory } }, { @@ -563,8 +542,38 @@ export const getSecretImports = async (req: Request, res: Response) => { } */ const { - query: { workspaceId, environment, folderId } + query: { workspaceId, environment, directory } } = await validateRequest(reqValidator.GetSecretImportsV1, req); + + if (req.authData.authPayload instanceof ServiceTokenData) { + const isValidScopeAccess = isValidScope(req.authData.authPayload, environment, directory); + if (!isValidScopeAccess) { + throw UnauthorizedRequestError({ message: "Folder Permission Denied" }); + } + } else { + const { permission } = await getUserProjectPermissions(req.user._id, workspaceId); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Read, + subject(ProjectPermissionSub.Secrets, { + environment, + secretPath: directory + }) + ); + } + + const folders = await Folder.findOne({ + workspace: workspaceId, + environment + }).lean(); + if (!folders && directory !== "/") throw BadRequestError({ message: "Folder not found" }); + + let folderId = "root"; + if (folders) { + const folder = getFolderByPath(folders.nodes, directory); + if (!folder) throw BadRequestError({ message: "Folder not found" }); + folderId = folder.id; + } + const importSecDoc = await SecretImport.findOne({ workspace: workspaceId, environment, @@ -575,41 +584,6 @@ export const getSecretImports = async (req: Request, res: Response) => { return res.status(200).json({ secretImport: {} }); } - // check for service token validity - const folders = await Folder.findOne({ - workspace: importSecDoc.workspace, - environment: importSecDoc.environment - }).lean(); - - let secretPath = "/"; - if (folders) { - const { folderPath } = getFolderWithPathFromId(folders.nodes, importSecDoc.folderId); - secretPath = folderPath; - } - - if (req.authData.authPayload instanceof ServiceTokenData) { - const isValidScopeAccess = isValidScope( - req.authData.authPayload, - importSecDoc.environment, - secretPath - ); - if (!isValidScopeAccess) { - throw UnauthorizedRequestError({ message: "Folder Permission Denied" }); - } - } else { - const { permission } = await getUserProjectPermissions( - req.user._id, - importSecDoc.workspace.toString() - ); - ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionActions.Read, - subject(ProjectPermissionSub.Secrets, { - environment: importSecDoc.environment, - secretPath - }) - ); - } - return res.status(200).json({ secretImport: importSecDoc }); }; @@ -621,9 +595,39 @@ export const getSecretImports = async (req: Request, res: Response) => { */ export const getAllSecretsFromImport = async (req: Request, res: Response) => { const { - query: { workspaceId, environment, folderId } + query: { workspaceId, environment, directory } } = await validateRequest(reqValidator.GetAllSecretsFromImportV1, req); + if (req.authData.authPayload instanceof ServiceTokenData) { + // check for service token validity + const isValidScopeAccess = isValidScope(req.authData.authPayload, environment, directory); + if (!isValidScopeAccess) { + throw UnauthorizedRequestError({ message: "Folder Permission Denied" }); + } + } else { + const { permission } = await getUserProjectPermissions(req.user._id, workspaceId); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Read, + subject(ProjectPermissionSub.Secrets, { + environment, + secretPath: directory + }) + ); + } + + const folders = await Folder.findOne({ + workspace: workspaceId, + environment + }).lean(); + if (!folders && directory !== "/") throw BadRequestError({ message: "Folder not found" }); + + let folderId = "root"; + if (folders) { + const folder = getFolderByPath(folders.nodes, directory); + if (!folder) throw BadRequestError({ message: "Folder not found" }); + folderId = folder.id; + } + const importSecDoc = await SecretImport.findOne({ workspace: workspaceId, environment, @@ -634,11 +638,6 @@ export const getAllSecretsFromImport = async (req: Request, res: Response) => { return res.status(200).json({ secrets: [] }); } - const folders = await Folder.findOne({ - workspace: importSecDoc.workspace, - environment: importSecDoc.environment - }).lean(); - let secretPath = "/"; if (folders) { const { folderPath } = getFolderWithPathFromId(folders.nodes, importSecDoc.folderId); diff --git a/backend/src/controllers/v1/secretsFolderController.ts b/backend/src/controllers/v1/secretsFolderController.ts index b6f60249c..d030bce9a 100644 --- a/backend/src/controllers/v1/secretsFolderController.ts +++ b/backend/src/controllers/v1/secretsFolderController.ts @@ -9,12 +9,10 @@ import { Secret, ServiceTokenData } from "../../models"; import { Folder } from "../../models/folder"; import { appendFolder, - deleteFolderById, generateFolderId, getAllFolderIds, getFolderByPath, getFolderWithPathFromId, - getParentFromFolderId, validateFolderName } from "../../services/FolderService"; import { @@ -25,13 +23,9 @@ import { import { BadRequestError, UnauthorizedRequestError } from "../../utils/errors"; import * as reqValidator from "../../validation/folders"; -/** - * Create folder with name [folderName] for workspace with id [workspaceId] - * and environment [environment] - * @param req - * @param res - * @returns - */ +const ERR_FOLDER_NOT_FOUND = BadRequestError({ message: "The folder doesn't exist" }); + +// verify workspace id/environment export const createFolder = async (req: Request, res: Response) => { /* #swagger.summary = 'Create a folder' @@ -107,7 +101,7 @@ export const createFolder = async (req: Request, res: Response) => { } */ const { - body: { workspaceId, environment, folderName, parentFolderId } + body: { workspaceId, environment, folderName, directory } } = await validateRequest(reqValidator.CreateFolderV1, req); if (!validateFolderName(folderName)) { @@ -116,33 +110,29 @@ export const createFolder = async (req: Request, res: Response) => { }); } + if (req.authData.authPayload instanceof ServiceTokenData) { + // token check + const isValidScopeAccess = isValidScope(req.authData.authPayload, environment, directory); + if (!isValidScopeAccess) { + throw UnauthorizedRequestError({ message: "Folder Permission Denied" }); + } + } else { + // user check + const { permission } = await getUserProjectPermissions(req.user._id, workspaceId); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Create, + subject(ProjectPermissionSub.Secrets, { environment, secretPath: directory }) + ); + } + const folders = await Folder.findOne({ workspace: workspaceId, environment }).lean(); - if (req.user) { - const { permission } = await getUserProjectPermissions(req.user._id, workspaceId); - const secretPath = - folders && parentFolderId - ? getFolderWithPathFromId(folders.nodes, parentFolderId).folderPath - : "/"; - ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionActions.Create, - subject(ProjectPermissionSub.Secrets, { environment, secretPath }) - ); - } - // space has no folders initialized - if (!folders) { - if (req.authData.authPayload instanceof ServiceTokenData) { - // root check - const isValidScopeAccess = isValidScope(req.authData.authPayload, environment, "/"); - if (!isValidScopeAccess) { - throw UnauthorizedRequestError({ message: "Folder Permission Denied" }); - } - } + if (directory !== "/") throw ERR_FOLDER_NOT_FOUND; const id = generateFolderId(); const folder = new Folder({ @@ -186,27 +176,10 @@ export const createFolder = async (req: Request, res: Response) => { return res.json({ folder: { id, name: folderName } }); } - const folder = appendFolder(folders.nodes, { folderName, parentFolderId }); - - await Folder.findByIdAndUpdate(folders._id, folders); - - const { folder: parentFolder, folderPath: parentFolderPath } = getFolderWithPathFromId( - folders.nodes, - parentFolderId || "root" - ); - - if (req.authData.authPayload instanceof ServiceTokenData) { - // root check - const isValidScopeAccess = isValidScope( - req.authData.authPayload, - environment, - parentFolderPath - ); - if (!isValidScopeAccess) { - throw UnauthorizedRequestError({ message: "Folder Permission Denied" }); - } - } + const parentFolder = getFolderByPath(folders.nodes, directory); + if (!parentFolder) throw ERR_FOLDER_NOT_FOUND; + const folder = appendFolder(folders.nodes, { folderName, parentFolderId: parentFolder.id }); await Folder.findByIdAndUpdate(folders._id, folders); const folderVersion = new FolderVersion({ @@ -219,11 +192,9 @@ export const createFolder = async (req: Request, res: Response) => { await EESecretService.takeSecretSnapshot({ workspaceId: new Types.ObjectId(workspaceId), environment, - folderId: parentFolderId + folderId: parentFolder.id }); - const { folderPath } = getFolderWithPathFromId(folders.nodes, folder.id); - await EEAuditLogService.createAuditLog( req.authData, { @@ -232,7 +203,7 @@ export const createFolder = async (req: Request, res: Response) => { environment, folderId: folder.id, folderName, - folderPath + folderPath: directory } }, { @@ -332,8 +303,8 @@ export const updateFolderById = async (req: Request, res: Response) => { } */ const { - body: { workspaceId, environment, name }, - params: { folderId } + body: { workspaceId, environment, name, directory }, + params: { folderName } } = await validateRequest(reqValidator.UpdateFolderV1, req); if (!validateFolderName(name)) { @@ -342,38 +313,31 @@ export const updateFolderById = async (req: Request, res: Response) => { }); } + if (req.authData.authPayload instanceof ServiceTokenData) { + const isValidScopeAccess = isValidScope(req.authData.authPayload, environment, directory); + if (!isValidScopeAccess) { + throw UnauthorizedRequestError({ message: "Folder Permission Denied" }); + } + } else { + const { permission } = await getUserProjectPermissions(req.user._id, workspaceId); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Edit, + subject(ProjectPermissionSub.Secrets, { environment, secretPath: directory }) + ); + } + const folders = await Folder.findOne({ workspace: workspaceId, environment }); if (!folders) { throw BadRequestError({ message: "The folder doesn't exist" }); } - const parentFolder = getParentFromFolderId(folders.nodes, folderId); + const parentFolder = getFolderByPath(folders.nodes, directory); if (!parentFolder) { throw BadRequestError({ message: "The folder doesn't exist" }); } - if (req.user) { - const { permission } = await getUserProjectPermissions(req.user._id, workspaceId); - const secretPath = getFolderWithPathFromId(folders.nodes, parentFolder.id).folderPath; - ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionActions.Edit, - subject(ProjectPermissionSub.Secrets, { environment, secretPath }) - ); - } - - const folder = parentFolder.children.find(({ id }) => id === folderId); - if (!folder) { - throw BadRequestError({ message: "The folder doesn't exist" }); - } - - if (req.authData.authPayload instanceof ServiceTokenData) { - const { folderPath: secretPath } = getFolderWithPathFromId(folders.nodes, parentFolder.id); - // root check - const isValidScopeAccess = isValidScope(req.authData.authPayload, environment, secretPath); - if (!isValidScopeAccess) { - throw UnauthorizedRequestError({ message: "Folder Permission Denied" }); - } - } + const folder = parentFolder.children.find(({ name }) => name === folderName); + if (!folder) throw ERR_FOLDER_NOT_FOUND; const oldFolderName = folder.name; parentFolder.version += 1; @@ -505,24 +469,12 @@ export const deleteFolder = async (req: Request, res: Response) => { } */ const { - params: { folderId }, - body: { environment, workspaceId } + params: { folderName }, + body: { environment, workspaceId, directory } } = await validateRequest(reqValidator.DeleteFolderV1, req); - const folders = await Folder.findOne({ workspace: workspaceId, environment }); - if (!folders) { - throw BadRequestError({ message: "The folder doesn't exist" }); - } - - const delOp = deleteFolderById(folders.nodes, folderId); - if (!delOp) { - throw BadRequestError({ message: "The folder doesn't exist" }); - } - const { deletedNode: delFolder, parent: parentFolder } = delOp; - const { folderPath: secretPath } = getFolderWithPathFromId(folders.nodes, parentFolder.id); - if (req.authData.authPayload instanceof ServiceTokenData) { - const isValidScopeAccess = isValidScope(req.authData.authPayload, environment, secretPath); + const isValidScopeAccess = isValidScope(req.authData.authPayload, environment, directory); if (!isValidScopeAccess) { throw UnauthorizedRequestError({ message: "Folder Permission Denied" }); } @@ -531,12 +483,23 @@ export const deleteFolder = async (req: Request, res: Response) => { const { permission } = await getUserProjectPermissions(req.user._id, workspaceId); ForbiddenError.from(permission).throwUnlessCan( ProjectPermissionActions.Delete, - subject(ProjectPermissionSub.Secrets, { environment, secretPath }) + subject(ProjectPermissionSub.Secrets, { environment, secretPath: directory }) ); } + const folders = await Folder.findOne({ workspace: workspaceId, environment }); + if (!folders) throw ERR_FOLDER_NOT_FOUND; + + const parentFolder = getFolderByPath(folders.nodes, directory); + if (!parentFolder) throw ERR_FOLDER_NOT_FOUND; + + const index = parentFolder.children.findIndex(({ name }) => name === folderName); + if (index === -1) throw ERR_FOLDER_NOT_FOUND; + + const deletedFolder = parentFolder.children.splice(index, 1)[0]; + parentFolder.version += 1; - const delFolderIds = getAllFolderIds(delFolder); + const delFolderIds = getAllFolderIds(deletedFolder); await Folder.findByIdAndUpdate(folders._id, folders); const folderVersion = new FolderVersion({ @@ -565,9 +528,9 @@ export const deleteFolder = async (req: Request, res: Response) => { type: EventType.DELETE_FOLDER, metadata: { environment, - folderId, - folderName: delFolder.name, - folderPath: secretPath + folderId: deletedFolder.id, + folderName: deletedFolder.name, + folderPath: directory } }, { @@ -575,7 +538,7 @@ export const deleteFolder = async (req: Request, res: Response) => { } ); - res.send({ message: "successfully deleted folders", folders: delFolderIds }); + return res.send({ message: "successfully deleted folders", folders: delFolderIds }); }; /** @@ -677,69 +640,27 @@ export const getFolders = async (req: Request, res: Response) => { } */ const { - query: { workspaceId, environment, parentFolderId, parentFolderPath } + query: { workspaceId, environment, directory } } = await validateRequest(reqValidator.GetFoldersV1, req); - const folders = await Folder.findOne({ workspace: workspaceId, environment }); - - if (req.user) await getUserProjectPermissions(req.user._id, workspaceId); - - if (!folders) { - res.send({ folders: [], dir: [] }); - return; - } - - // if instead of parentFolderId given a path like /folder1/folder2 - if (parentFolderPath) { - if (req.authData.authPayload instanceof ServiceTokenData) { - const isValidScopeAccess = isValidScope( - req.authData.authPayload, - environment, - parentFolderPath - ); - if (!isValidScopeAccess) { - throw UnauthorizedRequestError({ message: "Folder Permission Denied" }); - } - } - const folder = getFolderByPath(folders.nodes, parentFolderPath); - - if (!folder) { - res.send({ folders: [], dir: [] }); - return; - } - // dir is not needed at present as this is only used in overview section of secrets - res.send({ - folders: folder.children.map(({ id, name }) => ({ id, name })), - dir: [{ name: folder.name, id: folder.id }] - }); - } - - if (!parentFolderId) { - if (req.authData.authPayload instanceof ServiceTokenData) { - const isValidScopeAccess = isValidScope(req.authData.authPayload, environment, "/"); - if (!isValidScopeAccess) { - throw UnauthorizedRequestError({ message: "Folder Permission Denied" }); - } - } - - const rootFolders = folders.nodes.children.map(({ id, name }) => ({ - id, - name - })); - res.send({ folders: rootFolders }); - return; - } - - const { folder, folderPath, dir } = getFolderWithPathFromId(folders.nodes, parentFolderId); if (req.authData.authPayload instanceof ServiceTokenData) { - const isValidScopeAccess = isValidScope(req.authData.authPayload, environment, folderPath); + const isValidScopeAccess = isValidScope(req.authData.authPayload, environment, directory); if (!isValidScopeAccess) { throw UnauthorizedRequestError({ message: "Folder Permission Denied" }); } + } else { + // check that user is a member of the workspace + await getUserProjectPermissions(req.user._id, workspaceId); } - res.send({ - folders: folder.children.map(({ id, name }) => ({ id, name })), - dir + const folders = await Folder.findOne({ workspace: workspaceId, environment }); + if (!folders) { + return res.send({ folders: [], dir: [] }); + } + + const folder = getFolderByPath(folders.nodes, directory); + + return res.send({ + folders: folder?.children?.map(({ id, name }) => ({ id, name })) || [] }); }; diff --git a/backend/src/controllers/v3/secretsController.ts b/backend/src/controllers/v3/secretsController.ts index a553315bd..451de1294 100644 --- a/backend/src/controllers/v3/secretsController.ts +++ b/backend/src/controllers/v3/secretsController.ts @@ -196,7 +196,15 @@ export const getSecretByNameRaw = async (req: Request, res: Response) => { export const createSecretRaw = async (req: Request, res: Response) => { const { params: { secretName }, - body: { secretPath, environment, workspaceId, type, secretValue, secretComment } + body: { + secretPath, + environment, + workspaceId, + type, + secretValue, + secretComment, + skipMultilineEncoding + } } = await validateRequest(reqValidator.CreateSecretRawV3, req); if (req.user?._id) { @@ -249,7 +257,8 @@ export const createSecretRaw = async (req: Request, res: Response) => { secretPath, secretCommentCiphertext: secretCommentEncrypted.ciphertext, secretCommentIV: secretCommentEncrypted.iv, - secretCommentTag: secretCommentEncrypted.tag + secretCommentTag: secretCommentEncrypted.tag, + skipMultilineEncoding }); await EventService.handleEvent({ @@ -279,7 +288,7 @@ export const createSecretRaw = async (req: Request, res: Response) => { export const updateSecretByNameRaw = async (req: Request, res: Response) => { const { params: { secretName }, - body: { secretValue, environment, secretPath, type, workspaceId } + body: { secretValue, environment, secretPath, type, workspaceId, skipMultilineEncoding } } = await validateRequest(reqValidator.UpdateSecretByNameRawV3, req); if (req.user?._id) { @@ -316,7 +325,8 @@ export const updateSecretByNameRaw = async (req: Request, res: Response) => { secretValueCiphertext: secretValueEncrypted.ciphertext, secretValueIV: secretValueEncrypted.iv, secretValueTag: secretValueEncrypted.tag, - secretPath + secretPath, + skipMultilineEncoding }); await EventService.handleEvent({ @@ -540,7 +550,8 @@ export const createSecret = async (req: Request, res: Response) => { secretCommentTag, secretKeyCiphertext, secretValueCiphertext, - secretCommentCiphertext + secretCommentCiphertext, + skipMultilineEncoding }, params: { secretName } } = await validateRequest(reqValidator.CreateSecretV3, req); @@ -577,7 +588,8 @@ export const createSecret = async (req: Request, res: Response) => { secretCommentCiphertext, secretCommentIV, secretCommentTag, - metadata + metadata, + skipMultilineEncoding }); await EventService.handleEvent({ @@ -610,11 +622,23 @@ export const updateSecretByName = async (req: Request, res: Response) => { type, environment, secretPath, - workspaceId + workspaceId, + tags, + secretCommentIV, + secretCommentTag, + secretCommentCiphertext, + secretName: newSecretName, + secretKeyIV, + secretKeyTag, + secretKeyCiphertext, + skipMultilineEncoding }, params: { secretName } } = await validateRequest(reqValidator.UpdateSecretByNameV3, req); + if (newSecretName && (!secretKeyIV || !secretKeyTag || !secretKeyCiphertext)) + throw BadRequestError({ message: "Missing encrypted key" }); + if (req.user?._id) { const { permission } = await getUserProjectPermissions(req.user._id, workspaceId); ForbiddenError.from(permission).throwUnlessCan( @@ -637,10 +661,19 @@ export const updateSecretByName = async (req: Request, res: Response) => { environment, type, authData: req.authData, + newSecretName, secretValueCiphertext, secretValueIV, secretValueTag, - secretPath + secretPath, + tags, + secretCommentIV, + secretCommentTag, + secretCommentCiphertext, + skipMultilineEncoding, + secretKeyTag, + secretKeyCiphertext, + secretKeyIV }); await EventService.handleEvent({ @@ -704,3 +737,105 @@ export const deleteSecretByName = async (req: Request, res: Response) => { secret }); }; + +export const createSecretByNameBatch = async (req: Request, res: Response) => { + const { + body: { secrets, secretPath, environment, workspaceId } + } = await validateRequest(reqValidator.CreateSecretByNameBatchV3, req); + + if (req.user?._id) { + const { permission } = await getUserProjectPermissions(req.user._id, workspaceId); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Create, + subject(ProjectPermissionSub.Secrets, { environment, secretPath }) + ); + } else { + await validateServiceTokenDataClientForWorkspace({ + serviceTokenData: req.authData.authPayload as IServiceTokenData, + workspaceId: new Types.ObjectId(workspaceId), + environment, + secretPath, + requiredPermissions: [PERMISSION_WRITE_SECRETS] + }); + } + + const createdSecrets = await SecretService.createSecretBatch({ + secretPath, + environment, + workspaceId: new Types.ObjectId(workspaceId), + secrets, + authData: req.authData + }); + + return res.status(200).send({ + secrets: createdSecrets + }); +}; + +export const updateSecretByNameBatch = async (req: Request, res: Response) => { + const { + body: { secrets, secretPath, environment, workspaceId } + } = await validateRequest(reqValidator.UpdateSecretByNameBatchV3, req); + + if (req.user?._id) { + const { permission } = await getUserProjectPermissions(req.user._id, workspaceId); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Edit, + subject(ProjectPermissionSub.Secrets, { environment, secretPath }) + ); + } else { + await validateServiceTokenDataClientForWorkspace({ + serviceTokenData: req.authData.authPayload as IServiceTokenData, + workspaceId: new Types.ObjectId(workspaceId), + environment, + secretPath, + requiredPermissions: [PERMISSION_WRITE_SECRETS] + }); + } + + const updatedSecrets = await SecretService.updateSecretBatch({ + secretPath, + environment, + workspaceId: new Types.ObjectId(workspaceId), + secrets, + authData: req.authData + }); + + return res.status(200).send({ + secrets: updatedSecrets + }); +}; + +export const deleteSecretByNameBatch = async (req: Request, res: Response) => { + const { + body: { secrets, secretPath, environment, workspaceId } + } = await validateRequest(reqValidator.DeleteSecretByNameBatchV3, req); + + if (req.user?._id) { + const { permission } = await getUserProjectPermissions(req.user._id, workspaceId); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Delete, + subject(ProjectPermissionSub.Secrets, { environment, secretPath }) + ); + } else { + await validateServiceTokenDataClientForWorkspace({ + serviceTokenData: req.authData.authPayload as IServiceTokenData, + workspaceId: new Types.ObjectId(workspaceId), + environment, + secretPath, + requiredPermissions: [PERMISSION_WRITE_SECRETS] + }); + } + + const deletedSecrets = await SecretService.deleteSecretBatch({ + secretPath, + environment, + workspaceId: new Types.ObjectId(workspaceId), + secrets, + authData: req.authData + }); + + return res.status(200).send({ + secrets: deletedSecrets + }); +}; diff --git a/backend/src/ee/controllers/v1/workspaceController.ts b/backend/src/ee/controllers/v1/workspaceController.ts index 7c9502656..cef139c5c 100644 --- a/backend/src/ee/controllers/v1/workspaceController.ts +++ b/backend/src/ee/controllers/v1/workspaceController.ts @@ -27,7 +27,7 @@ import { import { EESecretService } from "../../services"; import { getLatestSecretVersionIds } from "../../helpers/secretVersion"; // import Folder, { TFolderSchema } from "../../../models/folder"; -import { searchByFolderId } from "../../../services/FolderService"; +import { getFolderByPath, searchByFolderId } from "../../../services/FolderService"; import { EEAuditLogService, EELicenseService } from "../../services"; import { extractIPDetails, isValidIpOrCidr } from "../../../utils/ip"; import { validateRequest } from "../../../helpers/validation"; @@ -104,7 +104,7 @@ export const getWorkspaceSecretSnapshots = async (req: Request, res: Response) = */ const { params: { workspaceId }, - query: { environment, folderId, offset, limit } + query: { environment, directory, offset, limit } } = await validateRequest(GetWorkspaceSecretSnapshotsV1, req); const { permission } = await getUserProjectPermissions(req.user._id, workspaceId); @@ -113,10 +113,20 @@ export const getWorkspaceSecretSnapshots = async (req: Request, res: Response) = ProjectPermissionSub.SecretRollback ); + let folderId = "root"; + const folders = await Folder.findOne({ workspace: workspaceId, environment }); + if (!folders && directory !== "/") throw BadRequestError({ message: "Folder not found" }); + + if (folders) { + const folder = getFolderByPath(folders?.nodes, directory); + if (!folder) throw BadRequestError({ message: "Invalid folder id" }); + folderId = folder.id; + } + const secretSnapshots = await SecretSnapshot.find({ workspace: workspaceId, environment, - folderId: folderId || "root" + folderId }) .sort({ createdAt: -1 }) .skip(offset) @@ -135,7 +145,7 @@ export const getWorkspaceSecretSnapshots = async (req: Request, res: Response) = export const getWorkspaceSecretSnapshotsCount = async (req: Request, res: Response) => { const { params: { workspaceId }, - query: { environment, folderId } + query: { environment, directory } } = await validateRequest(GetWorkspaceSecretSnapshotsCountV1, req); const { permission } = await getUserProjectPermissions(req.user._id, workspaceId); @@ -144,10 +154,20 @@ export const getWorkspaceSecretSnapshotsCount = async (req: Request, res: Respon ProjectPermissionSub.SecretRollback ); + let folderId = "root"; + const folders = await Folder.findOne({ workspace: workspaceId, environment }); + if (!folders && directory !== "/") throw BadRequestError({ message: "Folder not found" }); + + if (folders) { + const folder = getFolderByPath(folders?.nodes, directory); + if (!folder) throw BadRequestError({ message: "Invalid folder id" }); + folderId = folder.id; + } + const count = await SecretSnapshot.countDocuments({ workspace: workspaceId, environment, - folderId: folderId || "root" + folderId }); return res.status(200).send({ @@ -215,7 +235,7 @@ export const rollbackWorkspaceSecretSnapshot = async (req: Request, res: Respons const { params: { workspaceId }, - body: { folderId, environment, version } + body: { directory, environment, version } } = await validateRequest(RollbackWorkspaceSecretSnapshotV1, req); const { permission } = await getUserProjectPermissions(req.user._id, workspaceId); @@ -224,6 +244,16 @@ export const rollbackWorkspaceSecretSnapshot = async (req: Request, res: Respons ProjectPermissionSub.SecretRollback ); + let folderId = "root"; + const folders = await Folder.findOne({ workspace: workspaceId, environment }); + if (!folders && directory !== "/") throw BadRequestError({ message: "Folder not found" }); + + if (folders) { + const folder = getFolderByPath(folders?.nodes, directory); + if (!folder) throw BadRequestError({ message: "Invalid folder id" }); + folderId = folder.id; + } + // validate secret snapshot const secretSnapshot = await SecretSnapshot.findOne({ workspace: workspaceId, diff --git a/backend/src/ee/models/auditLog/enums.ts b/backend/src/ee/models/auditLog/enums.ts index dda49c1cd..c39b26502 100644 --- a/backend/src/ee/models/auditLog/enums.ts +++ b/backend/src/ee/models/auditLog/enums.ts @@ -1,47 +1,50 @@ export enum ActorType { - USER = "user", - SERVICE = "service" + USER = "user", + SERVICE = "service" } export enum UserAgentType { - WEB = "web", - CLI = "cli", - K8_OPERATOR = "k8-operator", - OTHER = "other" + WEB = "web", + CLI = "cli", + K8_OPERATOR = "k8-operator", + OTHER = "other" } export enum EventType { - GET_SECRETS = "get-secrets", - GET_SECRET = "get-secret", - REVEAL_SECRET = "reveal-secret", - CREATE_SECRET = "create-secret", - UPDATE_SECRET = "update-secret", - DELETE_SECRET = "delete-secret", - GET_WORKSPACE_KEY = "get-workspace-key", - AUTHORIZE_INTEGRATION = "authorize-integration", - UNAUTHORIZE_INTEGRATION = "unauthorize-integration", - CREATE_INTEGRATION = "create-integration", - DELETE_INTEGRATION = "delete-integration", - ADD_TRUSTED_IP = "add-trusted-ip", - UPDATE_TRUSTED_IP = "update-trusted-ip", - DELETE_TRUSTED_IP = "delete-trusted-ip", - CREATE_SERVICE_TOKEN = "create-service-token", - DELETE_SERVICE_TOKEN = "delete-service-token", - CREATE_ENVIRONMENT = "create-environment", - UPDATE_ENVIRONMENT = "update-environment", - DELETE_ENVIRONMENT = "delete-environment", - ADD_WORKSPACE_MEMBER = "add-workspace-member", - REMOVE_WORKSPACE_MEMBER = "remove-workspace-member", - CREATE_FOLDER = "create-folder", - UPDATE_FOLDER = "update-folder", - DELETE_FOLDER = "delete-folder", - CREATE_WEBHOOK = "create-webhook", - UPDATE_WEBHOOK_STATUS = "update-webhook-status", - DELETE_WEBHOOK = "delete-webhook", - GET_SECRET_IMPORTS = "get-secret-imports", - CREATE_SECRET_IMPORT = "create-secret-import", - UPDATE_SECRET_IMPORT = "update-secret-import", - DELETE_SECRET_IMPORT = "delete-secret-import", - UPDATE_USER_WORKSPACE_ROLE = "update-user-workspace-role", - UPDATE_USER_WORKSPACE_DENIED_PERMISSIONS = "update-user-workspace-denied-permissions" -} \ No newline at end of file + GET_SECRETS = "get-secrets", + GET_SECRET = "get-secret", + REVEAL_SECRET = "reveal-secret", + CREATE_SECRET = "create-secret", + CREATE_SECRETS = "create-secrets", + UPDATE_SECRET = "update-secret", + UPDATE_SECRETS = "update-secrets", + DELETE_SECRET = "delete-secret", + DELETE_SECRETS = "delete-secrets", + GET_WORKSPACE_KEY = "get-workspace-key", + AUTHORIZE_INTEGRATION = "authorize-integration", + UNAUTHORIZE_INTEGRATION = "unauthorize-integration", + CREATE_INTEGRATION = "create-integration", + DELETE_INTEGRATION = "delete-integration", + ADD_TRUSTED_IP = "add-trusted-ip", + UPDATE_TRUSTED_IP = "update-trusted-ip", + DELETE_TRUSTED_IP = "delete-trusted-ip", + CREATE_SERVICE_TOKEN = "create-service-token", + DELETE_SERVICE_TOKEN = "delete-service-token", + CREATE_ENVIRONMENT = "create-environment", + UPDATE_ENVIRONMENT = "update-environment", + DELETE_ENVIRONMENT = "delete-environment", + ADD_WORKSPACE_MEMBER = "add-workspace-member", + REMOVE_WORKSPACE_MEMBER = "remove-workspace-member", + CREATE_FOLDER = "create-folder", + UPDATE_FOLDER = "update-folder", + DELETE_FOLDER = "delete-folder", + CREATE_WEBHOOK = "create-webhook", + UPDATE_WEBHOOK_STATUS = "update-webhook-status", + DELETE_WEBHOOK = "delete-webhook", + GET_SECRET_IMPORTS = "get-secret-imports", + CREATE_SECRET_IMPORT = "create-secret-import", + UPDATE_SECRET_IMPORT = "update-secret-import", + DELETE_SECRET_IMPORT = "delete-secret-import", + UPDATE_USER_WORKSPACE_ROLE = "update-user-workspace-role", + UPDATE_USER_WORKSPACE_DENIED_PERMISSIONS = "update-user-workspace-denied-permissions" +} diff --git a/backend/src/ee/models/auditLog/types.ts b/backend/src/ee/models/auditLog/types.ts index 71507212e..c03b109db 100644 --- a/backend/src/ee/models/auditLog/types.ts +++ b/backend/src/ee/models/auditLog/types.ts @@ -1,403 +1,428 @@ -import { - ActorType, - EventType -} from "./enums"; +import { ActorType, EventType } from "./enums"; interface UserActorMetadata { - userId: string; - email: string; + userId: string; + email: string; } interface ServiceActorMetadata { - serviceId: string; - name: string; + serviceId: string; + name: string; } export interface UserActor { - type: ActorType.USER; - metadata: UserActorMetadata; + type: ActorType.USER; + metadata: UserActorMetadata; } export interface ServiceActor { - type: ActorType.SERVICE; - metadata: ServiceActorMetadata; + type: ActorType.SERVICE; + metadata: ServiceActorMetadata; } -export type Actor = - | UserActor - | ServiceActor; +export type Actor = UserActor | ServiceActor; interface GetSecretsEvent { - type: EventType.GET_SECRETS; - metadata: { - environment: string; - secretPath: string; - numberOfSecrets: number; - }; + type: EventType.GET_SECRETS; + metadata: { + environment: string; + secretPath: string; + numberOfSecrets: number; + }; } interface GetSecretEvent { - type: EventType.GET_SECRET; - metadata: { - environment: string; - secretPath: string; - secretId: string; - secretKey: string; - secretVersion: number; - }; + type: EventType.GET_SECRET; + metadata: { + environment: string; + secretPath: string; + secretId: string; + secretKey: string; + secretVersion: number; + }; } interface CreateSecretEvent { - type: EventType.CREATE_SECRET; - metadata: { - environment: string; - secretPath: string; - secretId: string; - secretKey: string; - secretVersion: number; - } + type: EventType.CREATE_SECRET; + metadata: { + environment: string; + secretPath: string; + secretId: string; + secretKey: string; + secretVersion: number; + }; +} + +interface CreateSecretBatchEvent { + type: EventType.CREATE_SECRETS; + metadata: { + environment: string; + secretPath: string; + secrets: Array<{ secretId: string; secretKey: string; secretVersion: number }>; + }; } interface UpdateSecretEvent { - type: EventType.UPDATE_SECRET; - metadata: { - environment: string; - secretPath: string; - secretId: string; - secretKey: string; - secretVersion: number; - } + type: EventType.UPDATE_SECRET; + metadata: { + environment: string; + secretPath: string; + secretId: string; + secretKey: string; + secretVersion: number; + }; +} + +interface UpdateSecretBatchEvent { + type: EventType.UPDATE_SECRETS; + metadata: { + environment: string; + secretPath: string; + secrets: Array<{ secretId: string; secretKey: string; secretVersion: number }>; + }; } interface DeleteSecretEvent { - type: EventType.DELETE_SECRET; - metadata: { - environment: string; - secretPath: string; - secretId: string; - secretKey: string; - secretVersion: number; - } + type: EventType.DELETE_SECRET; + metadata: { + environment: string; + secretPath: string; + secretId: string; + secretKey: string; + secretVersion: number; + }; +} + +interface DeleteSecretBatchEvent { + type: EventType.DELETE_SECRETS; + metadata: { + environment: string; + secretPath: string; + secrets: Array<{ secretId: string; secretKey: string; secretVersion: number }>; + }; } interface GetWorkspaceKeyEvent { - type: EventType.GET_WORKSPACE_KEY, - metadata: { - keyId: string; - } + type: EventType.GET_WORKSPACE_KEY; + metadata: { + keyId: string; + }; } interface AuthorizeIntegrationEvent { - type: EventType.AUTHORIZE_INTEGRATION; - metadata: { - integration: string; - } + type: EventType.AUTHORIZE_INTEGRATION; + metadata: { + integration: string; + }; } interface UnauthorizeIntegrationEvent { - type: EventType.UNAUTHORIZE_INTEGRATION; - metadata: { - integration: string; - } + type: EventType.UNAUTHORIZE_INTEGRATION; + metadata: { + integration: string; + }; } interface CreateIntegrationEvent { - type: EventType.CREATE_INTEGRATION; - metadata: { - integrationId: string; - integration: string; // TODO: fix type - environment: string; - secretPath: string; - url?: string; - app?: string; - appId?: string; - targetEnvironment?: string; - targetEnvironmentId?: string; - targetService?: string; - targetServiceId?: string; - path?: string; - region?: string; - } + type: EventType.CREATE_INTEGRATION; + metadata: { + integrationId: string; + integration: string; // TODO: fix type + environment: string; + secretPath: string; + url?: string; + app?: string; + appId?: string; + targetEnvironment?: string; + targetEnvironmentId?: string; + targetService?: string; + targetServiceId?: string; + path?: string; + region?: string; + }; } interface DeleteIntegrationEvent { - type: EventType.DELETE_INTEGRATION; - metadata: { - integrationId: string; - integration: string; // TODO: fix type - environment: string; - secretPath: string; - url?: string; - app?: string; - appId?: string; - targetEnvironment?: string; - targetEnvironmentId?: string; - targetService?: string; - targetServiceId?: string; - path?: string; - region?: string; - } + type: EventType.DELETE_INTEGRATION; + metadata: { + integrationId: string; + integration: string; // TODO: fix type + environment: string; + secretPath: string; + url?: string; + app?: string; + appId?: string; + targetEnvironment?: string; + targetEnvironmentId?: string; + targetService?: string; + targetServiceId?: string; + path?: string; + region?: string; + }; } interface AddTrustedIPEvent { - type: EventType.ADD_TRUSTED_IP; - metadata: { - trustedIpId: string; - ipAddress: string; - prefix?: number; - } + type: EventType.ADD_TRUSTED_IP; + metadata: { + trustedIpId: string; + ipAddress: string; + prefix?: number; + }; } interface UpdateTrustedIPEvent { - type: EventType.UPDATE_TRUSTED_IP; - metadata: { - trustedIpId: string; - ipAddress: string; - prefix?: number; - } + type: EventType.UPDATE_TRUSTED_IP; + metadata: { + trustedIpId: string; + ipAddress: string; + prefix?: number; + }; } interface DeleteTrustedIPEvent { - type: EventType.DELETE_TRUSTED_IP; - metadata: { - trustedIpId: string; - ipAddress: string; - prefix?: number; - } + type: EventType.DELETE_TRUSTED_IP; + metadata: { + trustedIpId: string; + ipAddress: string; + prefix?: number; + }; } interface CreateServiceTokenEvent { - type: EventType.CREATE_SERVICE_TOKEN; - metadata: { - name: string; - scopes: Array<{ - environment: string; - secretPath: string; - }>; - } + type: EventType.CREATE_SERVICE_TOKEN; + metadata: { + name: string; + scopes: Array<{ + environment: string; + secretPath: string; + }>; + }; } interface DeleteServiceTokenEvent { - type: EventType.DELETE_SERVICE_TOKEN; - metadata: { - name: string; - scopes: Array<{ - environment: string; - secretPath: string; - }>; - } + type: EventType.DELETE_SERVICE_TOKEN; + metadata: { + name: string; + scopes: Array<{ + environment: string; + secretPath: string; + }>; + }; } interface CreateEnvironmentEvent { - type: EventType.CREATE_ENVIRONMENT; - metadata: { - name: string; - slug: string; - } + type: EventType.CREATE_ENVIRONMENT; + metadata: { + name: string; + slug: string; + }; } interface UpdateEnvironmentEvent { - type: EventType.UPDATE_ENVIRONMENT; - metadata: { - oldName: string; - newName: string; - oldSlug: string; - newSlug: string; - } + type: EventType.UPDATE_ENVIRONMENT; + metadata: { + oldName: string; + newName: string; + oldSlug: string; + newSlug: string; + }; } interface DeleteEnvironmentEvent { - type: EventType.DELETE_ENVIRONMENT; - metadata: { - name: string; - slug: string; - } + type: EventType.DELETE_ENVIRONMENT; + metadata: { + name: string; + slug: string; + }; } interface AddWorkspaceMemberEvent { - type: EventType.ADD_WORKSPACE_MEMBER; - metadata: { - userId: string; - email: string; - } + type: EventType.ADD_WORKSPACE_MEMBER; + metadata: { + userId: string; + email: string; + }; } interface RemoveWorkspaceMemberEvent { - type: EventType.REMOVE_WORKSPACE_MEMBER; - metadata: { - userId: string; - email: string; - } + type: EventType.REMOVE_WORKSPACE_MEMBER; + metadata: { + userId: string; + email: string; + }; } interface CreateFolderEvent { - type: EventType.CREATE_FOLDER; - metadata: { - environment: string; - folderId: string; - folderName: string; - folderPath: string; - } + type: EventType.CREATE_FOLDER; + metadata: { + environment: string; + folderId: string; + folderName: string; + folderPath: string; + }; } interface UpdateFolderEvent { - type: EventType.UPDATE_FOLDER; - metadata: { - environment: string; - folderId: string; - oldFolderName: string; - newFolderName: string; - folderPath: string; - } + type: EventType.UPDATE_FOLDER; + metadata: { + environment: string; + folderId: string; + oldFolderName: string; + newFolderName: string; + folderPath: string; + }; } interface DeleteFolderEvent { - type: EventType.DELETE_FOLDER; - metadata: { - environment: string; - folderId: string; - folderName: string; - folderPath: string; - } + type: EventType.DELETE_FOLDER; + metadata: { + environment: string; + folderId: string; + folderName: string; + folderPath: string; + }; } interface CreateWebhookEvent { - type: EventType.CREATE_WEBHOOK, - metadata: { - webhookId: string; - environment: string; - secretPath: string; - webhookUrl: string; - isDisabled: boolean; - } + type: EventType.CREATE_WEBHOOK; + metadata: { + webhookId: string; + environment: string; + secretPath: string; + webhookUrl: string; + isDisabled: boolean; + }; } interface UpdateWebhookStatusEvent { - type: EventType.UPDATE_WEBHOOK_STATUS, - metadata: { - webhookId: string; - environment: string; - secretPath: string; - webhookUrl: string; - isDisabled: boolean; - } + type: EventType.UPDATE_WEBHOOK_STATUS; + metadata: { + webhookId: string; + environment: string; + secretPath: string; + webhookUrl: string; + isDisabled: boolean; + }; } interface DeleteWebhookEvent { - type: EventType.DELETE_WEBHOOK, - metadata: { - webhookId: string; - environment: string; - secretPath: string; - webhookUrl: string; - isDisabled: boolean; - } + type: EventType.DELETE_WEBHOOK; + metadata: { + webhookId: string; + environment: string; + secretPath: string; + webhookUrl: string; + isDisabled: boolean; + }; } interface GetSecretImportsEvent { - type: EventType.GET_SECRET_IMPORTS, - metadata: { - environment: string; - secretImportId: string; - folderId: string; - numberOfImports: number; - } + type: EventType.GET_SECRET_IMPORTS; + metadata: { + environment: string; + secretImportId: string; + folderId: string; + numberOfImports: number; + }; } interface CreateSecretImportEvent { - type: EventType.CREATE_SECRET_IMPORT, - metadata: { - secretImportId: string; - folderId: string; - importFromEnvironment: string; - importFromSecretPath: string; - importToEnvironment: string; - importToSecretPath: string; - } + type: EventType.CREATE_SECRET_IMPORT; + metadata: { + secretImportId: string; + folderId: string; + importFromEnvironment: string; + importFromSecretPath: string; + importToEnvironment: string; + importToSecretPath: string; + }; } interface UpdateSecretImportEvent { - type: EventType.UPDATE_SECRET_IMPORT, - metadata: { - secretImportId: string; - folderId: string; - importToEnvironment: string; - importToSecretPath: string; - orderBefore: { - environment: string; - secretPath: string; - }[], - orderAfter: { - environment: string; - secretPath: string; - }[] - } + type: EventType.UPDATE_SECRET_IMPORT; + metadata: { + secretImportId: string; + folderId: string; + importToEnvironment: string; + importToSecretPath: string; + orderBefore: { + environment: string; + secretPath: string; + }[]; + orderAfter: { + environment: string; + secretPath: string; + }[]; + }; } interface DeleteSecretImportEvent { - type: EventType.DELETE_SECRET_IMPORT, - metadata: { - secretImportId: string; - folderId: string; - importFromEnvironment: string; - importFromSecretPath: string; - importToEnvironment: string; - importToSecretPath: string; - } + type: EventType.DELETE_SECRET_IMPORT; + metadata: { + secretImportId: string; + folderId: string; + importFromEnvironment: string; + importFromSecretPath: string; + importToEnvironment: string; + importToSecretPath: string; + }; } interface UpdateUserRole { - type: EventType.UPDATE_USER_WORKSPACE_ROLE, - metadata: { - userId: string; - email: string; - oldRole: string; - newRole: string; - } + type: EventType.UPDATE_USER_WORKSPACE_ROLE; + metadata: { + userId: string; + email: string; + oldRole: string; + newRole: string; + }; } interface UpdateUserDeniedPermissions { - type: EventType.UPDATE_USER_WORKSPACE_DENIED_PERMISSIONS, - metadata: { - userId: string; - email: string; - deniedPermissions: { - environmentSlug: string; - ability: string; - }[] - } + type: EventType.UPDATE_USER_WORKSPACE_DENIED_PERMISSIONS; + metadata: { + userId: string; + email: string; + deniedPermissions: { + environmentSlug: string; + ability: string; + }[]; + }; } -export type Event = - | GetSecretsEvent - | GetSecretEvent - | CreateSecretEvent - | UpdateSecretEvent - | DeleteSecretEvent - | GetWorkspaceKeyEvent - | AuthorizeIntegrationEvent - | UnauthorizeIntegrationEvent - | CreateIntegrationEvent - | DeleteIntegrationEvent - | AddTrustedIPEvent - | UpdateTrustedIPEvent - | DeleteTrustedIPEvent - | CreateServiceTokenEvent - | DeleteServiceTokenEvent - | CreateEnvironmentEvent - | UpdateEnvironmentEvent - | DeleteEnvironmentEvent - | AddWorkspaceMemberEvent - | RemoveWorkspaceMemberEvent - | CreateFolderEvent - | UpdateFolderEvent - | DeleteFolderEvent - | CreateWebhookEvent - | UpdateWebhookStatusEvent - | DeleteWebhookEvent - | GetSecretImportsEvent - | CreateSecretImportEvent - | UpdateSecretImportEvent - | DeleteSecretImportEvent - | UpdateUserRole - | UpdateUserDeniedPermissions; \ No newline at end of file +export type Event = + | GetSecretsEvent + | GetSecretEvent + | CreateSecretEvent + | CreateSecretBatchEvent + | UpdateSecretEvent + | UpdateSecretBatchEvent + | DeleteSecretEvent + | DeleteSecretBatchEvent + | GetWorkspaceKeyEvent + | AuthorizeIntegrationEvent + | UnauthorizeIntegrationEvent + | CreateIntegrationEvent + | DeleteIntegrationEvent + | AddTrustedIPEvent + | UpdateTrustedIPEvent + | DeleteTrustedIPEvent + | CreateServiceTokenEvent + | DeleteServiceTokenEvent + | CreateEnvironmentEvent + | UpdateEnvironmentEvent + | DeleteEnvironmentEvent + | AddWorkspaceMemberEvent + | RemoveWorkspaceMemberEvent + | CreateFolderEvent + | UpdateFolderEvent + | DeleteFolderEvent + | CreateWebhookEvent + | UpdateWebhookStatusEvent + | DeleteWebhookEvent + | GetSecretImportsEvent + | CreateSecretImportEvent + | UpdateSecretImportEvent + | DeleteSecretImportEvent + | UpdateUserRole + | UpdateUserDeniedPermissions; diff --git a/backend/src/ee/models/secretVersion.ts b/backend/src/ee/models/secretVersion.ts index 84174ac87..11ffa79ab 100644 --- a/backend/src/ee/models/secretVersion.ts +++ b/backend/src/ee/models/secretVersion.ts @@ -4,7 +4,7 @@ import { ENCODING_SCHEME_BASE64, ENCODING_SCHEME_UTF8, SECRET_PERSONAL, - SECRET_SHARED, + SECRET_SHARED } from "../../variables"; export interface ISecretVersion { @@ -23,6 +23,7 @@ export interface ISecretVersion { secretValueCiphertext: string; secretValueIV: string; secretValueTag: string; + skipMultilineEncoding?: boolean; algorithm: "aes-256-gcm"; keyEncoding: "utf8" | "base64"; createdAt: string; @@ -36,95 +37,96 @@ const secretVersionSchema = new Schema( // could be deleted type: Schema.Types.ObjectId, ref: "Secret", - required: true, + required: true }, version: { type: Number, default: 1, - required: true, + required: true }, workspace: { type: Schema.Types.ObjectId, ref: "Workspace", - required: true, + required: true }, type: { type: String, enum: [SECRET_SHARED, SECRET_PERSONAL], - required: true, + required: true }, user: { // user associated with the personal secret type: Schema.Types.ObjectId, - ref: "User", + ref: "User" }, environment: { type: String, - required: true, + required: true }, isDeleted: { // consider removing field type: Boolean, default: false, - required: true, + required: true }, secretBlindIndex: { type: String, - select: false, + select: false }, secretKeyCiphertext: { type: String, - required: true, + required: true }, secretKeyIV: { type: String, // symmetric - required: true, + required: true }, secretKeyTag: { type: String, // symmetric - required: true, + required: true }, secretValueCiphertext: { type: String, - required: true, + required: true }, secretValueIV: { type: String, // symmetric - required: true, + required: true }, secretValueTag: { type: String, // symmetric - required: true, + required: true + }, + skipMultilineEncoding: { + type: Boolean, + required: false }, algorithm: { // the encryption algorithm used type: String, enum: [ALGORITHM_AES_256_GCM], required: true, - default: ALGORITHM_AES_256_GCM, + default: ALGORITHM_AES_256_GCM }, keyEncoding: { type: String, enum: [ENCODING_SCHEME_UTF8, ENCODING_SCHEME_BASE64], required: true, - default: ENCODING_SCHEME_UTF8, + default: ENCODING_SCHEME_UTF8 }, folder: { type: String, - required: true, + required: true }, tags: { ref: "Tag", type: [Schema.Types.ObjectId], - default: [], + default: [] } }, { - timestamps: true, + timestamps: true } ); -export const SecretVersion = model( - "SecretVersion", - secretVersionSchema -); \ No newline at end of file +export const SecretVersion = model("SecretVersion", secretVersionSchema); diff --git a/backend/src/helpers/secrets.ts b/backend/src/helpers/secrets.ts index 5b4c8b465..ec40c9016 100644 --- a/backend/src/helpers/secrets.ts +++ b/backend/src/helpers/secrets.ts @@ -1,9 +1,12 @@ import { Types } from "mongoose"; import { + CreateSecretBatchParams, CreateSecretParams, + DeleteSecretBatchParams, DeleteSecretParams, GetSecretParams, GetSecretsParams, + UpdateSecretBatchParams, UpdateSecretParams } from "../interfaces/services/SecretService"; import { @@ -71,6 +74,8 @@ export function containsGlobPatterns(secretPath: string) { return globChars.some((char) => normalizedPath.includes(char)); } +const ERR_FOLDER_NOT_FOUND = BadRequestError({ message: "Folder not found" }); + /** * Returns an object containing secret [secret] but with its value, key, comment decrypted. * @@ -330,7 +335,8 @@ export const createSecretHelper = async ({ secretCommentIV, secretCommentTag, secretPath = "/", - metadata + metadata, + skipMultilineEncoding }: CreateSecretParams) => { const secretBlindIndex = await generateSecretBlindIndexHelper({ secretName, @@ -394,6 +400,7 @@ export const createSecretHelper = async ({ secretCommentCiphertext, secretCommentIV, secretCommentTag, + skipMultilineEncoding, folder: folderId, algorithm: ALGORITHM_AES_256_GCM, keyEncoding: ENCODING_SCHEME_UTF8, @@ -416,6 +423,7 @@ export const createSecretHelper = async ({ secretValueCiphertext, secretValueIV, secretValueTag, + skipMultilineEncoding, algorithm: ALGORITHM_AES_256_GCM, keyEncoding: ENCODING_SCHEME_UTF8 }); @@ -740,19 +748,50 @@ export const updateSecretHelper = async ({ environment, type, authData, + newSecretName, + secretKeyTag, + secretKeyCiphertext, + secretKeyIV, secretValueCiphertext, secretValueIV, secretValueTag, - secretPath + secretPath, + tags, + secretCommentCiphertext, + secretCommentIV, + secretCommentTag, + skipMultilineEncoding }: UpdateSecretParams) => { - const secretBlindIndex = await generateSecretBlindIndexHelper({ - secretName, + // get secret blind index salt + const salt = await getSecretBlindIndexSaltHelper({ workspaceId: new Types.ObjectId(workspaceId) }); + const secretBlindIndex = await generateSecretBlindIndexWithSaltHelper({ + secretName, + salt + }); + let secret: ISecret | null = null; const folderId = await getFolderIdFromServiceToken(workspaceId, environment, secretPath); + let newSecretNameBlindIndex = undefined; + if (newSecretName) { + newSecretNameBlindIndex = await generateSecretBlindIndexWithSaltHelper({ + secretName, + salt + }); + const doesSecretAlreadyExist = await Secret.exists({ + secretBlindIndex: newSecretNameBlindIndex, + workspace: new Types.ObjectId(workspaceId), + environment, + folder: folderId, + type + }); + if (!doesSecretAlreadyExist) + throw BadRequestError({ message: "Secret with the provided name already exist" }); + } + if (type === SECRET_SHARED) { // case: update shared secret secret = await Secret.findOneAndUpdate( @@ -767,6 +806,15 @@ export const updateSecretHelper = async ({ secretValueCiphertext, secretValueIV, secretValueTag, + secretCommentIV, + secretCommentTag, + secretCommentCiphertext, + skipMultilineEncoding, + secretBlindIndex: newSecretNameBlindIndex, + secretKeyIV, + secretKeyTag, + secretKeyCiphertext, + tags, $inc: { version: 1 } }, { @@ -782,6 +830,9 @@ export const updateSecretHelper = async ({ workspace: new Types.ObjectId(workspaceId), environment, type, + secretKeyIV, + secretKeyTag, + secretKeyCiphertext, folder: folderId, ...getAuthDataPayloadUserObj(authData) }, @@ -789,6 +840,9 @@ export const updateSecretHelper = async ({ secretValueCiphertext, secretValueIV, secretValueTag, + tags, + skipMultilineEncoding, + secretBlindIndex: newSecretNameBlindIndex, $inc: { version: 1 } }, { @@ -805,16 +859,18 @@ export const updateSecretHelper = async ({ workspace: secret.workspace, folder: folderId, type, + tags, ...(type === SECRET_PERSONAL ? getAuthDataPayloadUserObj(authData) : {}), environment: secret.environment, isDeleted: false, - secretBlindIndex, + secretBlindIndex: newSecretName ? newSecretNameBlindIndex : secretBlindIndex, secretKeyCiphertext: secret.secretKeyCiphertext, secretKeyIV: secret.secretKeyIV, secretKeyTag: secret.secretKeyTag, secretValueCiphertext, secretValueIV, secretValueTag, + skipMultilineEncoding, algorithm: ALGORITHM_AES_256_GCM, keyEncoding: ENCODING_SCHEME_UTF8 }); @@ -1185,3 +1241,550 @@ export const expandSecrets = async ( return secrets; }; + +export const createSecretBatchHelper = async ({ + secrets, + workspaceId, + authData, + secretPath, + environment +}: CreateSecretBatchParams) => { + let folderId = "root"; + const folders = await Folder.findOne({ + workspace: workspaceId, + environment + }); + + if (!folders && secretPath !== "/") throw ERR_FOLDER_NOT_FOUND; + if (folders) { + const folder = getFolderByPath(folders.nodes, secretPath); + if (!folder) throw ERR_FOLDER_NOT_FOUND; + folderId = folder.id; + } + + // get secret blind index salt + const salt = await getSecretBlindIndexSaltHelper({ + workspaceId: new Types.ObjectId(workspaceId) + }); + + const secretBlindIndexToKey: Record = {}; // used at audit log point + const secretBlindIndexes = await Promise.all( + secrets.map(({ secretName }) => + generateSecretBlindIndexWithSaltHelper({ + secretName, + salt + }) + ) + ).then((blindIndexes) => + blindIndexes.reduce>((prev, curr, i) => { + prev[secrets[i].secretName] = curr; + secretBlindIndexToKey[curr] = secrets[i].secretName; + return prev; + }, {}) + ); + + const exists = await Secret.exists({ + workspace: new Types.ObjectId(workspaceId), + folder: folderId, + environment + }) + .or( + secrets.map(({ secretName, type }) => ({ + secretBlindIndex: secretBlindIndexes[secretName], + type: type, + ...getAuthDataPayloadUserObj(authData) + })) + ) + .exec(); + + if (exists) + throw BadRequestError({ + message: "Failed to create secret that already exists" + }); + + // create secret + const newlyCreatedSecrets: ISecret[] = await Secret.insertMany( + secrets.map( + ({ + type, + secretName, + secretKeyIV, + metadata, + secretKeyTag, + secretValueIV, + secretValueTag, + secretCommentIV, + secretCommentTag, + secretKeyCiphertext, + secretValueCiphertext, + secretCommentCiphertext, + skipMultilineEncoding + }) => ({ + version: 1, + workspace: new Types.ObjectId(workspaceId), + environment, + type, + secretKeyCiphertext, + secretKeyIV, + secretKeyTag, + secretValueCiphertext, + secretValueIV, + secretValueTag, + secretCommentCiphertext, + secretCommentIV, + secretCommentTag, + folder: folderId, + algorithm: ALGORITHM_AES_256_GCM, + keyEncoding: ENCODING_SCHEME_UTF8, + metadata, + skipMultilineEncoding, + secretBlindIndex: secretBlindIndexes[secretName], + ...(type === SECRET_PERSONAL ? getAuthDataPayloadUserObj(authData) : {}) + }) + ) + ); + + await EESecretService.addSecretVersions({ + secretVersions: newlyCreatedSecrets.map( + (secret) => + new SecretVersion({ + secret: secret._id, + version: secret.version, + workspace: secret.workspace, + type: secret.type, + folder: folderId, + skipMultilineEncoding: secret?.skipMultilineEncoding, + ...(secret.type === SECRET_PERSONAL ? getAuthDataPayloadUserObj(authData) : {}), + environment: secret.environment, + isDeleted: false, + secretBlindIndex: secret.secretBlindIndex, + secretKeyCiphertext: secret.secretKeyCiphertext, + secretKeyIV: secret.secretKeyIV, + secretKeyTag: secret.secretKeyTag, + secretValueCiphertext: secret.secretValueCiphertext, + secretValueIV: secret.secretValueIV, + secretValueTag: secret.secretValueTag, + algorithm: ALGORITHM_AES_256_GCM, + keyEncoding: ENCODING_SCHEME_UTF8 + }) + ) + }); + + // (EE) create (audit) log + const action = await EELogService.createAction({ + name: ACTION_ADD_SECRETS, + ...getAuthDataPayloadIdObj(authData), + workspaceId, + secretIds: newlyCreatedSecrets.map((n) => n._id) + }); + + action && + (await EELogService.createLog({ + ...getAuthDataPayloadIdObj(authData), + workspaceId, + actions: [action], + channel: authData.userAgentType, + ipAddress: authData.ipAddress + })); + + await EEAuditLogService.createAuditLog( + authData, + { + type: EventType.CREATE_SECRETS, + metadata: { + environment, + secretPath, + secrets: newlyCreatedSecrets.map(({ secretBlindIndex, version, _id }) => ({ + secretId: _id.toString(), + secretKey: secretBlindIndexToKey[secretBlindIndex || ""], + secretVersion: version + })) + } + }, + { + workspaceId + } + ); + + // (EE) take a secret snapshot + await EESecretService.takeSecretSnapshot({ + workspaceId, + environment, + folderId + }); + + const postHogClient = await TelemetryService.getPostHogClient(); + + if (postHogClient) { + postHogClient.capture({ + event: "secrets added", + distinctId: await TelemetryService.getDistinctId({ + authData + }), + properties: { + numberOfSecrets: 1, + environment, + workspaceId, + folderId, + channel: authData.userAgentType, + userAgent: authData.userAgent + } + }); + } + + return newlyCreatedSecrets; +}; + +export const updateSecretBatchHelper = async ({ + workspaceId, + environment, + authData, + secretPath, + secrets +}: UpdateSecretBatchParams) => { + let folderId = "root"; + const folders = await Folder.findOne({ + workspace: workspaceId, + environment + }); + + if (!folders && secretPath !== "/") throw ERR_FOLDER_NOT_FOUND; + if (folders) { + const folder = getFolderByPath(folders.nodes, secretPath); + if (!folder) throw ERR_FOLDER_NOT_FOUND; + folderId = folder.id; + } + + // get secret blind index salt + const salt = await getSecretBlindIndexSaltHelper({ + workspaceId: new Types.ObjectId(workspaceId) + }); + + const secretBlindIndexToKey: Record = {}; // used at audit log point + const secretBlindIndexes = await Promise.all( + secrets.map(({ secretName }) => + generateSecretBlindIndexWithSaltHelper({ + secretName, + salt + }) + ) + ).then((blindIndexes) => + blindIndexes.reduce>((prev, curr, i) => { + prev[secrets[i].secretName] = curr; + secretBlindIndexToKey[curr] = secrets[i].secretName; + return prev; + }, {}) + ); + + const secretsToBeUpdated = await Secret.find({ + workspace: new Types.ObjectId(workspaceId), + folder: folderId, + environment + }) + .select("+secretBlindIndex") + .or( + secrets.map(({ secretName, type }) => ({ + secretBlindIndex: secretBlindIndexes[secretName], + type: type, + ...(type === SECRET_PERSONAL ? getAuthDataPayloadUserObj(authData) : {}) + })) + ) + .lean(); + + if (secretsToBeUpdated.length !== secrets.length) + throw BadRequestError({ message: "Some secrets not found" }); + + await Secret.bulkWrite( + secrets.map( + ({ + type, + secretName, + tags, + secretValueIV, + secretValueTag, + secretCommentIV, + secretCommentTag, + secretValueCiphertext, + secretCommentCiphertext, + skipMultilineEncoding + }) => ({ + updateOne: { + filter: { + workspace: new Types.ObjectId(workspaceId), + environment, + folder: folderId, + secretBlindIndex: secretBlindIndexes[secretName], + type, + ...(type === SECRET_PERSONAL ? getAuthDataPayloadUserObj(authData) : {}) + }, + update: { + $inc: { + version: 1 + }, + secretValueCiphertext, + secretValueIV, + secretValueTag, + secretCommentCiphertext, + secretCommentIV, + secretCommentTag, + algorithm: ALGORITHM_AES_256_GCM, + keyEncoding: ENCODING_SCHEME_UTF8, + tags, + skipMultilineEncoding + } + } + }) + ) + ); + + const secretsGroupedByBlindIndex = secretsToBeUpdated.reduce>( + (prev, curr) => { + if (curr.secretBlindIndex) prev[curr.secretBlindIndex] = curr; + return prev; + }, + {} + ); + + await EESecretService.addSecretVersions({ + secretVersions: secrets.map((secret) => { + const { + _id, + version, + workspace, + type, + secretBlindIndex, + secretKeyIV, + secretKeyTag, + secretKeyCiphertext, + skipMultilineEncoding + } = secretsGroupedByBlindIndex[secretBlindIndexes[secret.secretName]]; + + return new SecretVersion({ + secret: _id, + version: version + 1, + workspace: workspace, + type, + folder: folderId, + ...(secret.type === SECRET_PERSONAL ? getAuthDataPayloadUserObj(authData) : {}), + environment, + isDeleted: false, + secretBlindIndex: secretBlindIndex, + secretKeyCiphertext: secretKeyCiphertext, + secretKeyIV: secretKeyIV, + secretKeyTag: secretKeyTag, + secretValueCiphertext: secret.secretValueCiphertext, + secretValueIV: secret.secretValueIV, + secretValueTag: secret.secretValueTag, + algorithm: ALGORITHM_AES_256_GCM, + keyEncoding: ENCODING_SCHEME_UTF8, + skipMultilineEncoding + }); + }) + }); + + // (EE) create (audit) log + const action = await EELogService.createAction({ + name: ACTION_UPDATE_SECRETS, + ...getAuthDataPayloadIdObj(authData), + workspaceId, + secretIds: secretsToBeUpdated.map(({ _id }) => _id) + }); + + action && + (await EELogService.createLog({ + ...getAuthDataPayloadIdObj(authData), + workspaceId, + actions: [action], + channel: authData.userAgentType, + ipAddress: authData.ipAddress + })); + + await EEAuditLogService.createAuditLog( + authData, + { + type: EventType.UPDATE_SECRETS, + metadata: { + environment, + secretPath, + secrets: secretsToBeUpdated.map(({ _id, version, secretBlindIndex }) => ({ + secretId: _id.toString(), + secretKey: secretBlindIndexToKey[secretBlindIndex || ""], + secretVersion: version + 1 + })) + } + }, + { + workspaceId + } + ); + + // (EE) take a secret snapshot + await EESecretService.takeSecretSnapshot({ + workspaceId, + environment, + folderId + }); + + const postHogClient = await TelemetryService.getPostHogClient(); + + if (postHogClient) { + postHogClient.capture({ + event: "secrets modified", + distinctId: await TelemetryService.getDistinctId({ + authData + }), + properties: { + numberOfSecrets: 1, + environment, + workspaceId, + folderId, + channel: authData.userAgentType, + userAgent: authData.userAgent + } + }); + } + + return; +}; + +export const deleteSecretBatchHelper = async ({ + workspaceId, + environment, + authData, + secretPath = "/", + secrets +}: DeleteSecretBatchParams) => { + let folderId = "root"; + const folders = await Folder.findOne({ + workspace: workspaceId, + environment + }); + + if (!folders && secretPath !== "/") throw ERR_FOLDER_NOT_FOUND; + if (folders) { + const folder = getFolderByPath(folders.nodes, secretPath); + if (!folder) throw ERR_FOLDER_NOT_FOUND; + folderId = folder.id; + } + + // get secret blind index salt + const salt = await getSecretBlindIndexSaltHelper({ + workspaceId: new Types.ObjectId(workspaceId) + }); + + const secretBlindIndexToKey: Record = {}; // used at audit log point + const secretBlindIndexes = await Promise.all( + secrets.map(({ secretName }) => + generateSecretBlindIndexWithSaltHelper({ + secretName, + salt + }) + ) + ).then((blindIndexes) => + blindIndexes.reduce>((prev, curr, i) => { + prev[secrets[i].secretName] = curr; + secretBlindIndexToKey[curr] = secrets[i].secretName; + return prev; + }, {}) + ); + + const deletedSecrets = await Secret.find({ + workspace: new Types.ObjectId(workspaceId), + folder: folderId, + environment + }) + .or( + secrets.map(({ secretName, type }) => ({ + secretBlindIndex: secretBlindIndexes[secretName], + type: type === "shared" ? { $in: ["shared", "personal"] } : type, + ...(type === SECRET_PERSONAL ? getAuthDataPayloadUserObj(authData) : {}) + })) + ) + .select({ secretBlindIndexes: 1 }) + .lean() + .exec(); + + await Secret.deleteMany({ + workspace: new Types.ObjectId(workspaceId), + folder: folderId, + environment + }) + .or( + secrets.map(({ secretName, type }) => ({ + secretBlindIndex: secretBlindIndexes[secretName], + type: type === "shared" ? { $in: ["shared", "personal"] } : type, + ...(type === SECRET_PERSONAL ? getAuthDataPayloadUserObj(authData) : {}) + })) + ) + .exec(); + + await EESecretService.markDeletedSecretVersions({ + secretIds: deletedSecrets.map((secret) => secret._id) + }); + + // (EE) create (audit) log + const action = await EELogService.createAction({ + name: ACTION_DELETE_SECRETS, + ...getAuthDataPayloadIdObj(authData), + workspaceId, + secretIds: deletedSecrets.map((secret) => secret._id) + }); + + action && + (await EELogService.createLog({ + ...getAuthDataPayloadIdObj(authData), + workspaceId, + actions: [action], + channel: authData.userAgentType, + ipAddress: authData.ipAddress + })); + + await EEAuditLogService.createAuditLog( + authData, + { + type: EventType.DELETE_SECRETS, + metadata: { + environment, + secretPath, + secrets: deletedSecrets.map(({ _id, version, secretBlindIndex }) => ({ + secretId: _id.toString(), + secretKey: secretBlindIndexToKey[secretBlindIndex || ""], + secretVersion: version + })) + } + }, + { + workspaceId + } + ); + + // (EE) take a secret snapshot + await EESecretService.takeSecretSnapshot({ + workspaceId, + environment, + folderId + }); + + const postHogClient = await TelemetryService.getPostHogClient(); + + if (postHogClient) { + postHogClient.capture({ + event: "secrets deleted", + distinctId: await TelemetryService.getDistinctId({ + authData + }), + properties: { + numberOfSecrets: secrets.length, + environment, + workspaceId, + folderId, + channel: authData.userAgentType, + userAgent: authData.userAgent + } + }); + } + + return { + secrets: deletedSecrets + }; +}; diff --git a/backend/src/interfaces/services/SecretService/index.ts b/backend/src/interfaces/services/SecretService/index.ts index f1f89a83b..f42bbadf6 100644 --- a/backend/src/interfaces/services/SecretService/index.ts +++ b/backend/src/interfaces/services/SecretService/index.ts @@ -16,6 +16,7 @@ export interface CreateSecretParams { secretCommentCiphertext?: string; secretCommentIV?: string; secretCommentTag?: string; + skipMultilineEncoding?: boolean; secretPath: string; metadata?: { source?: string; @@ -42,6 +43,10 @@ export interface GetSecretParams { export interface UpdateSecretParams { secretName: string; + newSecretName?: string; + secretKeyCiphertext?: string; + secretKeyIV?: string; + secretKeyTag?: string; workspaceId: Types.ObjectId; environment: string; type: "shared" | "personal"; @@ -50,6 +55,11 @@ export interface UpdateSecretParams { secretValueIV: string; secretValueTag: string; secretPath: string; + secretCommentCiphertext?: string; + secretCommentIV?: string; + secretCommentTag?: string; + skipMultilineEncoding?: boolean; + tags?: string[]; } export interface DeleteSecretParams { @@ -60,3 +70,57 @@ export interface DeleteSecretParams { authData: AuthData; secretPath: string; } + +export interface CreateSecretBatchParams { + workspaceId: Types.ObjectId; + environment: string; + authData: AuthData; + secretPath: string; + secrets: Array<{ + secretName: string; + type: "shared" | "personal"; + secretKeyCiphertext: string; + secretKeyIV: string; + secretKeyTag: string; + secretValueCiphertext: string; + secretValueIV: string; + secretValueTag: string; + secretCommentCiphertext?: string; + secretCommentIV?: string; + secretCommentTag?: string; + skipMultilineEncoding?: boolean; + metadata?: { + source?: string; + }; + }>; +} + +export interface UpdateSecretBatchParams { + workspaceId: Types.ObjectId; + environment: string; + authData: AuthData; + secretPath: string; + secrets: Array<{ + secretName: string; + type: "shared" | "personal"; + secretValueCiphertext: string; + secretValueIV: string; + secretValueTag: string; + secretCommentCiphertext?: string; + secretCommentIV?: string; + secretCommentTag?: string; + skipMultilineEncoding?: boolean; + tags?: string[]; + }>; +} + +export interface DeleteSecretBatchParams { + workspaceId: Types.ObjectId; + environment: string; + authData: AuthData; + secretPath: string; + secrets: Array<{ + secretName: string; + type: "shared" | "personal"; + }>; +} diff --git a/backend/src/models/secret.ts b/backend/src/models/secret.ts index b7d7c266d..8592e518a 100644 --- a/backend/src/models/secret.ts +++ b/backend/src/models/secret.ts @@ -4,7 +4,7 @@ import { ENCODING_SCHEME_BASE64, ENCODING_SCHEME_UTF8, SECRET_PERSONAL, - SECRET_SHARED, + SECRET_SHARED } from "../variables"; export interface ISecret { @@ -12,7 +12,7 @@ export interface ISecret { version: number; workspace: Types.ObjectId; type: string; - user: Types.ObjectId; + user?: Types.ObjectId; environment: string; secretBlindIndex?: string; secretKeyCiphertext: string; @@ -27,13 +27,14 @@ export interface ISecret { secretCommentIV?: string; secretCommentTag?: string; secretCommentHash?: string; + skipMultilineEncoding?: boolean; algorithm: "aes-256-gcm"; keyEncoding: "utf8" | "base64"; tags?: string[]; folder?: string; metadata?: { [key: string]: string; - } + }; } const secretSchema = new Schema( @@ -41,108 +42,112 @@ const secretSchema = new Schema( version: { type: Number, required: true, - default: 1, + default: 1 }, workspace: { type: Schema.Types.ObjectId, ref: "Workspace", - required: true, + required: true }, type: { type: String, enum: [SECRET_SHARED, SECRET_PERSONAL], - required: true, + required: true }, user: { // user associated with the personal secret type: Schema.Types.ObjectId, - ref: "User", + ref: "User" }, tags: { ref: "Tag", type: [Schema.Types.ObjectId], - default: [], + default: [] }, environment: { type: String, - required: true, + required: true }, secretBlindIndex: { type: String, - select: false, + select: false }, secretKeyCiphertext: { type: String, - required: true, + required: true }, secretKeyIV: { type: String, // symmetric - required: true, + required: true }, secretKeyTag: { type: String, // symmetric - required: true, + required: true }, secretKeyHash: { - type: String, + type: String }, secretValueCiphertext: { type: String, - required: true, + required: true }, secretValueIV: { type: String, // symmetric - required: true, + required: true }, secretValueTag: { type: String, // symmetric - required: true, + required: true }, secretValueHash: { - type: String, + type: String }, secretCommentCiphertext: { type: String, - required: false, + required: false }, secretCommentIV: { type: String, // symmetric - required: false, + required: false }, secretCommentTag: { type: String, // symmetric - required: false, + required: false }, secretCommentHash: { type: String, - required: false, + required: false + }, + skipMultilineEncoding: { + type: Boolean, + required: false }, algorithm: { // the encryption algorithm used type: String, enum: [ALGORITHM_AES_256_GCM], required: true, - default: ALGORITHM_AES_256_GCM, + default: ALGORITHM_AES_256_GCM }, keyEncoding: { type: String, enum: [ENCODING_SCHEME_UTF8, ENCODING_SCHEME_BASE64], required: true, - default: ENCODING_SCHEME_UTF8, + default: ENCODING_SCHEME_UTF8 }, folder: { type: String, - default: "root", + default: "root" }, metadata: { type: Schema.Types.Mixed } }, { - timestamps: true, + timestamps: true } ); secretSchema.index({ tags: 1 }, { background: true }); -export const Secret = model("Secret", secretSchema); \ No newline at end of file +export const Secret = model("Secret", secretSchema); diff --git a/backend/src/routes/v1/secretsFolder.ts b/backend/src/routes/v1/secretsFolder.ts index 00ca2acea..697c4d3c9 100644 --- a/backend/src/routes/v1/secretsFolder.ts +++ b/backend/src/routes/v1/secretsFolder.ts @@ -18,7 +18,7 @@ router.post( ); router.patch( - "/:folderId", + "/:folderName", requireAuth({ acceptedAuthModes: [AuthMode.JWT, AuthMode.SERVICE_TOKEN] }), @@ -26,7 +26,7 @@ router.patch( ); router.delete( - "/:folderId", + "/:folderName", requireAuth({ acceptedAuthModes: [AuthMode.JWT, AuthMode.SERVICE_TOKEN] }), diff --git a/backend/src/routes/v3/secrets.ts b/backend/src/routes/v3/secrets.ts index 1765c2e33..0c4aff6a0 100644 --- a/backend/src/routes/v3/secrets.ts +++ b/backend/src/routes/v3/secrets.ts @@ -1,14 +1,8 @@ import express from "express"; const router = express.Router(); -import { - requireAuth, - requireBlindIndicesEnabled, - requireE2EEOff -} from "../../middleware"; +import { requireAuth, requireBlindIndicesEnabled, requireE2EEOff } from "../../middleware"; import { secretsController } from "../../controllers/v3"; -import { - AuthMode -} from "../../variables"; +import { AuthMode } from "../../variables"; router.get( "/raw", @@ -85,6 +79,40 @@ router.get( secretsController.getSecrets ); +// akhilmhdh: dont put batch router below the individual operation as those have arbitory name as params +router.post( + "/batch", + requireAuth({ + acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY, AuthMode.SERVICE_TOKEN] + }), + requireBlindIndicesEnabled({ + locationWorkspaceId: "body" + }), + secretsController.createSecretByNameBatch +); + +router.patch( + "/batch", + requireAuth({ + acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY, AuthMode.SERVICE_TOKEN] + }), + requireBlindIndicesEnabled({ + locationWorkspaceId: "body" + }), + secretsController.updateSecretByNameBatch +); + +router.delete( + "/batch", + requireAuth({ + acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY, AuthMode.SERVICE_TOKEN] + }), + requireBlindIndicesEnabled({ + locationWorkspaceId: "body" + }), + secretsController.deleteSecretByNameBatch +); + router.post( "/:secretName", requireAuth({ diff --git a/backend/src/services/SecretImportService.ts b/backend/src/services/SecretImportService.ts index d07c841f5..ffe4df9c6 100644 --- a/backend/src/services/SecretImportService.ts +++ b/backend/src/services/SecretImportService.ts @@ -108,14 +108,6 @@ export const getAllImportedSecrets = async ( type: "shared" } }, - { - $lookup: { - from: "tags", // note this is the name of the collection in the database, not the Mongoose model name - localField: "tags", - foreignField: "_id", - as: "tags" - } - }, { $group: { _id: { diff --git a/backend/src/services/SecretService.ts b/backend/src/services/SecretService.ts index ef372bddc..109fe1507 100644 --- a/backend/src/services/SecretService.ts +++ b/backend/src/services/SecretService.ts @@ -1,21 +1,27 @@ import { Types } from "mongoose"; import { - CreateSecretParams, - DeleteSecretParams, - GetSecretParams, - GetSecretsParams, - UpdateSecretParams, + CreateSecretBatchParams, + CreateSecretParams, + DeleteSecretBatchParams, + DeleteSecretParams, + GetSecretParams, + GetSecretsParams, + UpdateSecretBatchParams, + UpdateSecretParams } from "../interfaces/services/SecretService"; -import { - createSecretBlindIndexDataHelper, - createSecretHelper, - deleteSecretHelper, - generateSecretBlindIndexHelper, - generateSecretBlindIndexWithSaltHelper, - getSecretBlindIndexSaltHelper, - getSecretHelper, - getSecretsHelper, - updateSecretHelper, +import { + createSecretBatchHelper, + createSecretBlindIndexDataHelper, + createSecretHelper, + deleteSecretBatchHelper, + deleteSecretHelper, + generateSecretBlindIndexHelper, + generateSecretBlindIndexWithSaltHelper, + getSecretBlindIndexSaltHelper, + getSecretHelper, + getSecretsHelper, + updateSecretBatchHelper, + updateSecretHelper } from "../helpers/secrets"; class SecretService { @@ -26,13 +32,9 @@ class SecretService { * @param {Buffer} obj.salt - 16-byte random salt * @param {Types.ObjectId} obj.workspaceId */ - static async createSecretBlindIndexData({ - workspaceId, - }: { - workspaceId: Types.ObjectId; - }) { + static async createSecretBlindIndexData({ workspaceId }: { workspaceId: Types.ObjectId }) { return await createSecretBlindIndexDataHelper({ - workspaceId, + workspaceId }); } @@ -42,13 +44,9 @@ class SecretService { * @param {Types.ObjectId} obj.workspaceId - id of workspace to get salt for * @returns */ - static async getSecretBlindIndexSalt({ - workspaceId, - }: { - workspaceId: Types.ObjectId; - }) { + static async getSecretBlindIndexSalt({ workspaceId }: { workspaceId: Types.ObjectId }) { return await getSecretBlindIndexSaltHelper({ - workspaceId, + workspaceId }); } @@ -61,14 +59,14 @@ class SecretService { */ static async generateSecretBlindIndexWithSalt({ secretName, - salt, + salt }: { secretName: string; salt: string; }) { return await generateSecretBlindIndexWithSaltHelper({ secretName, - salt, + salt }); } @@ -81,14 +79,14 @@ class SecretService { */ static async generateSecretBlindIndex({ secretName, - workspaceId, + workspaceId }: { secretName: string; workspaceId: Types.ObjectId; }) { return await generateSecretBlindIndexHelper({ secretName, - workspaceId, + workspaceId }); } @@ -163,6 +161,18 @@ class SecretService { static async deleteSecret(deleteSecretParams: DeleteSecretParams) { return await deleteSecretHelper(deleteSecretParams); } + + static async createSecretBatch(createSecretParams: CreateSecretBatchParams) { + return await createSecretBatchHelper(createSecretParams); + } + + static async updateSecretBatch(updateSecretParams: UpdateSecretBatchParams) { + return await updateSecretBatchHelper(updateSecretParams); + } + + static async deleteSecretBatch(deleteSecretParams: DeleteSecretBatchParams) { + return await deleteSecretBatchHelper(deleteSecretParams); + } } export default SecretService; diff --git a/backend/src/validation/folders.ts b/backend/src/validation/folders.ts index ecfadea1c..deda48b03 100644 --- a/backend/src/validation/folders.ts +++ b/backend/src/validation/folders.ts @@ -5,28 +5,30 @@ export const CreateFolderV1 = z.object({ workspaceId: z.string().trim(), environment: z.string().trim(), folderName: z.string().trim(), - parentFolderId: z.string().trim().optional() + directory: z.string().trim().default("/") }) }); export const UpdateFolderV1 = z.object({ params: z.object({ - folderId: z.string().trim() + folderName: z.string().trim() }), body: z.object({ workspaceId: z.string().trim(), environment: z.string().trim(), - name: z.string().trim() + name: z.string().trim(), + directory: z.string().trim().default("/") }) }); export const DeleteFolderV1 = z.object({ params: z.object({ - folderId: z.string().trim() + folderName: z.string().trim() }), body: z.object({ workspaceId: z.string().trim(), - environment: z.string().trim() + environment: z.string().trim(), + directory: z.string().trim().default("/") }) }); @@ -34,7 +36,6 @@ export const GetFoldersV1 = z.object({ query: z.object({ workspaceId: z.string().trim(), environment: z.string().trim(), - parentFolderId: z.string().trim().optional(), - parentFolderPath: z.string().trim().optional() + directory: z.string().trim().default("/") }) }); diff --git a/backend/src/validation/secretImports.ts b/backend/src/validation/secretImports.ts index 92867a67b..a899a8346 100644 --- a/backend/src/validation/secretImports.ts +++ b/backend/src/validation/secretImports.ts @@ -4,7 +4,7 @@ export const CreateSecretImportV1 = z.object({ body: z.object({ workspaceId: z.string().trim(), environment: z.string().trim(), - folderId: z.string().trim().default("root"), + directory: z.string().trim().default("/"), secretImport: z.object({ environment: z.string().trim(), secretPath: z.string().trim() @@ -40,7 +40,7 @@ export const GetSecretImportsV1 = z.object({ query: z.object({ workspaceId: z.string().trim(), environment: z.string().trim(), - folderId: z.string().trim().default("root") + directory: z.string().trim().default("/") }) }); @@ -48,6 +48,6 @@ export const GetAllSecretsFromImportV1 = z.object({ query: z.object({ workspaceId: z.string().trim(), environment: z.string().trim(), - folderId: z.string().trim().default("root") + directory: z.string().trim().default("/") }) }); diff --git a/backend/src/validation/secrets.ts b/backend/src/validation/secrets.ts index 18579aa27..04a6458e6 100644 --- a/backend/src/validation/secrets.ts +++ b/backend/src/validation/secrets.ts @@ -259,6 +259,7 @@ export const CreateSecretRawV3 = z.object({ secretPath: z.string().trim().default("/"), secretValue: z.string().trim(), secretComment: z.string().trim(), + skipMultilineEncoding: z.boolean().optional(), type: z.enum([SECRET_SHARED, SECRET_PERSONAL]) }), params: z.object({ @@ -275,6 +276,7 @@ export const UpdateSecretByNameRawV3 = z.object({ environment: z.string().trim(), secretValue: z.string().trim(), secretPath: z.string().trim().default("/"), + skipMultilineEncoding: z.boolean().optional(), type: z.enum([SECRET_SHARED, SECRET_PERSONAL]).default(SECRET_SHARED) }) }); @@ -335,7 +337,8 @@ export const CreateSecretV3 = z.object({ secretCommentCiphertext: z.string().trim().optional(), secretCommentIV: z.string().trim().optional(), secretCommentTag: z.string().trim().optional(), - metadata: z.record(z.string()).optional() + metadata: z.record(z.string()).optional(), + skipMultilineEncoding: z.boolean().optional() }), params: z.object({ secretName: z.string().trim() @@ -350,7 +353,17 @@ export const UpdateSecretByNameV3 = z.object({ secretPath: z.string().trim().default("/"), secretValueCiphertext: z.string().trim(), secretValueIV: z.string().trim(), - secretValueTag: z.string().trim() + secretValueTag: z.string().trim(), + secretCommentCiphertext: z.string().trim().optional(), + secretCommentIV: z.string().trim().optional(), + secretCommentTag: z.string().trim().optional(), + tags: z.string().array().optional(), + skipMultilineEncoding: z.boolean().optional(), + // to update secret name + secretName: z.string().trim().optional(), + secretKeyIV: z.string().trim().optional(), + secretKeyTag: z.string().trim().optional(), + secretKeyCiphertext: z.string().trim().optional() }), params: z.object({ secretName: z.string() @@ -368,3 +381,67 @@ export const DeleteSecretByNameV3 = z.object({ secretName: z.string() }) }); + +export const CreateSecretByNameBatchV3 = z.object({ + body: z.object({ + workspaceId: z.string().trim(), + environment: z.string().trim(), + secretPath: z.string().trim().default("/"), + secrets: z + .object({ + secretName: z.string().trim(), + type: z.enum([SECRET_SHARED, SECRET_PERSONAL]), + secretKeyCiphertext: z.string().trim(), + secretKeyIV: z.string().trim(), + secretKeyTag: z.string().trim(), + secretValueCiphertext: z.string().trim(), + secretValueIV: z.string().trim(), + secretValueTag: z.string().trim(), + secretCommentCiphertext: z.string().trim().optional(), + secretCommentIV: z.string().trim().optional(), + secretCommentTag: z.string().trim().optional(), + metadata: z.record(z.string()).optional(), + skipMultilineEncoding: z.boolean().optional() + }) + .array() + .min(1) + }) +}); + +export const UpdateSecretByNameBatchV3 = z.object({ + body: z.object({ + workspaceId: z.string().trim(), + environment: z.string().trim(), + secretPath: z.string().trim().default("/"), + secrets: z + .object({ + secretName: z.string().trim(), + type: z.enum([SECRET_SHARED, SECRET_PERSONAL]), + secretValueCiphertext: z.string().trim(), + secretValueIV: z.string().trim(), + secretValueTag: z.string().trim(), + secretCommentCiphertext: z.string().trim().optional(), + secretCommentIV: z.string().trim().optional(), + secretCommentTag: z.string().trim().optional(), + skipMultilineEncoding: z.boolean().optional(), + tags: z.string().array().optional() + }) + .array() + .min(1) + }) +}); + +export const DeleteSecretByNameBatchV3 = z.object({ + body: z.object({ + workspaceId: z.string().trim(), + environment: z.string().trim(), + secretPath: z.string().trim().default("/"), + secrets: z + .object({ + secretName: z.string().trim(), + type: z.enum([SECRET_SHARED, SECRET_PERSONAL]) + }) + .array() + .min(1) + }) +}); diff --git a/backend/src/validation/workspace.ts b/backend/src/validation/workspace.ts index e0f7c156b..9d645f5d3 100644 --- a/backend/src/validation/workspace.ts +++ b/backend/src/validation/workspace.ts @@ -33,9 +33,10 @@ export const validateClientForWorkspace = async ({ }) => { const workspace = await Workspace.findById(workspaceId); - if (!workspace) throw WorkspaceNotFoundError({ - message: "Failed to find workspace" - }); + if (!workspace) + throw WorkspaceNotFoundError({ + message: "Failed to find workspace" + }); let membership; switch (authData.actor.type) { @@ -67,7 +68,7 @@ export const GetWorkspaceSecretSnapshotsV1 = z.object({ }), query: z.object({ environment: z.string().trim(), - folderId: z.string().trim().default("root"), + directory: z.string().trim().default("/"), offset: z.coerce.number(), limit: z.coerce.number() }) @@ -79,7 +80,7 @@ export const GetWorkspaceSecretSnapshotsCountV1 = z.object({ }), query: z.object({ environment: z.string().trim(), - folderId: z.string().trim().default("root") + directory: z.string().trim().default("/") }) }); @@ -89,7 +90,7 @@ export const RollbackWorkspaceSecretSnapshotV1 = z.object({ }), body: z.object({ environment: z.string().trim(), - folderId: z.string().trim().default("root"), + directory: z.string().trim().default("/"), version: z.number() }) }); diff --git a/backend/src/variables/secret.ts b/backend/src/variables/secret.ts index 74be3a1e9..b24d8a101 100644 --- a/backend/src/variables/secret.ts +++ b/backend/src/variables/secret.ts @@ -1,3 +1,3 @@ // secrets export const SECRET_SHARED = "shared"; -export const SECRET_PERSONAL = "personal"; \ No newline at end of file +export const SECRET_PERSONAL = "personal";