From 4f5946b25219f3b79e94055f02a32a1f2d8d1165 Mon Sep 17 00:00:00 2001 From: Akhil Mohan Date: Wed, 11 Oct 2023 17:19:34 +0530 Subject: [PATCH] feat: added support for recursive file creation --- .../controllers/v1/secretsFolderController.ts | 29 +++++------ .../src/controllers/v3/secretsController.ts | 3 +- backend/src/helpers/secrets.ts | 51 ++++++++++++------- .../services/SecretService/index.ts | 1 + backend/src/services/FolderService.ts | 37 ++++++++------ backend/src/validation/secrets.ts | 3 +- frontend/src/components/v2/Drawer/Drawer.tsx | 2 +- frontend/src/hooks/api/secrets/mutations.tsx | 12 ++++- frontend/src/hooks/api/secrets/types.ts | 1 + .../SecretListView/SecretListView.tsx | 11 ++-- .../SecretOverviewPage/SecretOverviewPage.tsx | 43 ++++++++++++++-- .../SecretOverviewTableRow/SecretEditRow.tsx | 8 +-- .../SecretOverviewTableRow.tsx | 3 +- 13 files changed, 138 insertions(+), 66 deletions(-) diff --git a/backend/src/controllers/v1/secretsFolderController.ts b/backend/src/controllers/v1/secretsFolderController.ts index d030bce9a..835551e76 100644 --- a/backend/src/controllers/v1/secretsFolderController.ts +++ b/backend/src/controllers/v1/secretsFolderController.ts @@ -9,7 +9,6 @@ import { Secret, ServiceTokenData } from "../../models"; import { Folder } from "../../models/folder"; import { appendFolder, - generateFolderId, getAllFolderIds, getFolderByPath, getFolderWithPathFromId, @@ -132,9 +131,6 @@ export const createFolder = async (req: Request, res: Response) => { // space has no folders initialized if (!folders) { - if (directory !== "/") throw ERR_FOLDER_NOT_FOUND; - - const id = generateFolderId(); const folder = new Folder({ workspace: workspaceId, environment, @@ -142,14 +138,15 @@ export const createFolder = async (req: Request, res: Response) => { id: "root", name: "root", version: 1, - children: [{ id, name: folderName, children: [], version: 1 }] + children: [] } }); + const { parent, child } = appendFolder(folder.nodes, { folderName, directory }); await folder.save(); const folderVersion = new FolderVersion({ workspace: workspaceId, environment, - nodes: folder.nodes + nodes: parent }); await folderVersion.save(); await EESecretService.takeSecretSnapshot({ @@ -163,9 +160,9 @@ export const createFolder = async (req: Request, res: Response) => { type: EventType.CREATE_FOLDER, metadata: { environment, - folderId: id, + folderId: child.id, folderName, - folderPath: `root/${folderName}` + folderPath: directory } }, { @@ -173,26 +170,26 @@ export const createFolder = async (req: Request, res: Response) => { } ); - return res.json({ folder: { id, name: folderName } }); + return res.json({ folder: { id: child.id, name: folderName } }); } - const parentFolder = getFolderByPath(folders.nodes, directory); - if (!parentFolder) throw ERR_FOLDER_NOT_FOUND; + const { parent, child, hasCreated } = appendFolder(folders.nodes, { folderName, directory }); + + if (!hasCreated) return res.json({ folder: child }); - const folder = appendFolder(folders.nodes, { folderName, parentFolderId: parentFolder.id }); await Folder.findByIdAndUpdate(folders._id, folders); const folderVersion = new FolderVersion({ workspace: workspaceId, environment, - nodes: parentFolder + nodes: parent }); await folderVersion.save(); await EESecretService.takeSecretSnapshot({ workspaceId: new Types.ObjectId(workspaceId), environment, - folderId: parentFolder.id + folderId: child.id }); await EEAuditLogService.createAuditLog( @@ -201,7 +198,7 @@ export const createFolder = async (req: Request, res: Response) => { type: EventType.CREATE_FOLDER, metadata: { environment, - folderId: folder.id, + folderId: child.id, folderName, folderPath: directory } @@ -211,7 +208,7 @@ export const createFolder = async (req: Request, res: Response) => { } ); - return res.json({ folder }); + return res.json({ folder: child }); }; /** diff --git a/backend/src/controllers/v3/secretsController.ts b/backend/src/controllers/v3/secretsController.ts index 31a727e02..4db0dca94 100644 --- a/backend/src/controllers/v3/secretsController.ts +++ b/backend/src/controllers/v3/secretsController.ts @@ -777,7 +777,7 @@ export const updateSecretByName = async (req: Request, res: Response) => { */ export const deleteSecretByName = async (req: Request, res: Response) => { const { - body: { type, environment, secretPath, workspaceId }, + body: { type, environment, secretPath, workspaceId, secretId }, params: { secretName } } = await validateRequest(reqValidator.DeleteSecretByNameV3, req); @@ -813,6 +813,7 @@ export const deleteSecretByName = async (req: Request, res: Response) => { const { secret } = await SecretService.deleteSecret({ secretName, + secretId, workspaceId: new Types.ObjectId(workspaceId), environment, type, diff --git a/backend/src/helpers/secrets.ts b/backend/src/helpers/secrets.ts index 6f718f5fe..ea6f4a254 100644 --- a/backend/src/helpers/secrets.ts +++ b/backend/src/helpers/secrets.ts @@ -57,10 +57,10 @@ import { getAnImportedSecret } from "../services/SecretImportService"; /** * Validate scope for service token v3 - * @param authPayload - * @param environment - * @param secretPath - * @returns + * @param authPayload + * @param environment + * @param secretPath + * @returns */ export const isValidScopeV3 = ({ authPayload, @@ -68,37 +68,40 @@ export const isValidScopeV3 = ({ secretPath, requiredPermissions }: { - authPayload: IServiceTokenDataV3, - environment: string, - secretPath: string, - requiredPermissions: Permission[] + authPayload: IServiceTokenDataV3; + environment: string; + secretPath: string; + requiredPermissions: Permission[]; }) => { const { scopes } = authPayload; - + const validScope = scopes.find( (scope) => picomatch.isMatch(secretPath, scope.secretPath, { strictSlashes: false }) && scope.environment === environment ); - if (validScope && !requiredPermissions.every(permission => validScope.permissions.includes(permission))) { + if ( + validScope && + !requiredPermissions.every((permission) => validScope.permissions.includes(permission)) + ) { return false; } - + return Boolean(validScope); -} +}; /** * Validate scope for service token v2 - * @param authPayload - * @param environment - * @param secretPath - * @returns + * @param authPayload + * @param environment + * @param secretPath + * @returns */ export const isValidScope = ( authPayload: IServiceTokenData, environment: string, - secretPath: string, + secretPath: string ) => { const { scopes: tkScopes } = authPayload; const validScope = tkScopes.find( @@ -1000,12 +1003,22 @@ export const deleteSecretHelper = async ({ environment, type, authData, - secretPath = "/" + secretPath = "/", + // used for update corner case and blindIndex goes wrong way + secretId }: DeleteSecretParams) => { - const secretBlindIndex = await generateSecretBlindIndexHelper({ + let secretBlindIndex = await generateSecretBlindIndexHelper({ secretName, workspaceId: new Types.ObjectId(workspaceId) }); + if (secretId) { + const secret = await Secret.findOne({ + workspace: workspaceId, + environment, + _id: secretId + }).select("secretBlindIndex"); + if (secret && secret.secretBlindIndex) secretBlindIndex = secret.secretBlindIndex; + } const folderId = await getFolderIdFromServiceToken(workspaceId, environment, secretPath); diff --git a/backend/src/interfaces/services/SecretService/index.ts b/backend/src/interfaces/services/SecretService/index.ts index f42bbadf6..203f0e178 100644 --- a/backend/src/interfaces/services/SecretService/index.ts +++ b/backend/src/interfaces/services/SecretService/index.ts @@ -64,6 +64,7 @@ export interface UpdateSecretParams { export interface DeleteSecretParams { secretName: string; + secretId?: string; workspaceId: Types.ObjectId; environment: string; type: "shared" | "personal"; diff --git a/backend/src/services/FolderService.ts b/backend/src/services/FolderService.ts index cb4ef4286..65aa5b0f3 100644 --- a/backend/src/services/FolderService.ts +++ b/backend/src/services/FolderService.ts @@ -5,7 +5,7 @@ import path from "path"; type TAppendFolderDTO = { folderName: string; - parentFolderId?: string; + directory: string; }; type TRenameFolderDTO = { @@ -50,9 +50,8 @@ export const folderBfsTraversal = async ( // 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"); - } + if (folder) return { folder, hasCreated: false }; + const id = generateFolderId(); folders.version += 1; folders.children.push({ @@ -61,24 +60,32 @@ const appendChild = (folders: TFolderSchema, folderName: string) => { children: [], version: 1 }); - return { id, name: folderName }; + // last element that is the new one + return { folder: folders.children[folders.children.length - 1], hasCreated: true }; }; // root of append child wrapper export const appendFolder = ( folders: TFolderSchema, - { folderName, parentFolderId }: TAppendFolderDTO -) => { - const isRoot = !parentFolderId; + { folderName, directory }: TAppendFolderDTO +): { parent: TFolderSchema; child: TFolderSchema; hasCreated?: boolean } => { + if (directory === "/") { + const newFolder = appendChild(folders, folderName); + return { parent: folders, child: newFolder.folder, hasCreated: newFolder.hasCreated }; + } - if (isRoot) { - return appendChild(folders, folderName); + const segments = directory.split("/").filter(Boolean); + const segment = segments.shift(); + if (segment) { + const nestedFolders = appendChild(folders, segment); + return appendFolder(nestedFolders.folder, { + folderName, + directory: path.join("/", ...segments) + }); } - const folder = searchByFolderId(folders, parentFolderId); - if (!folder) { - throw new Error("Parent Folder not found"); - } - return appendChild(folder, folderName); + + const newFolder = appendChild(folders, folderName); + return { parent: folders, child: newFolder.folder, hasCreated: newFolder.hasCreated }; }; export const renameFolder = ( diff --git a/backend/src/validation/secrets.ts b/backend/src/validation/secrets.ts index ea9a24e6c..264eb7271 100644 --- a/backend/src/validation/secrets.ts +++ b/backend/src/validation/secrets.ts @@ -379,7 +379,8 @@ export const DeleteSecretByNameV3 = z.object({ workspaceId: z.string().trim(), environment: z.string().trim(), type: z.enum([SECRET_SHARED, SECRET_PERSONAL]), - secretPath: z.string().trim().default("/") + secretPath: z.string().trim().default("/"), + secretId: z.string().trim().optional() }), params: z.object({ secretName: z.string() diff --git a/frontend/src/components/v2/Drawer/Drawer.tsx b/frontend/src/components/v2/Drawer/Drawer.tsx index 3bfcbe527..3a92c14db 100644 --- a/frontend/src/components/v2/Drawer/Drawer.tsx +++ b/frontend/src/components/v2/Drawer/Drawer.tsx @@ -37,7 +37,7 @@ export const DrawerContent = forwardRef( ) => ( ({ - mutationFn: async ({ secretPath = "/", type, environment, workspaceId, secretName }) => { + mutationFn: async ({ + secretPath = "/", + type, + environment, + workspaceId, + secretName, + secretId + }) => { const reqBody = { workspaceId, environment, type, - secretPath + secretPath, + secretId }; const { data } = await apiRequest.delete(`/api/v3/secrets/${secretName}`, { diff --git a/frontend/src/hooks/api/secrets/types.ts b/frontend/src/hooks/api/secrets/types.ts index 253ce77a6..ceccf286c 100644 --- a/frontend/src/hooks/api/secrets/types.ts +++ b/frontend/src/hooks/api/secrets/types.ts @@ -120,6 +120,7 @@ export type TDeleteSecretsV3DTO = { type: "shared" | "personal"; secretPath: string; secretName: string; + secretId?: string; }; export type TCreateSecretBatchDTO = { diff --git a/frontend/src/views/SecretMainPage/components/SecretListView/SecretListView.tsx b/frontend/src/views/SecretMainPage/components/SecretListView/SecretListView.tsx index 3863e55bf..1a09fe796 100644 --- a/frontend/src/views/SecretMainPage/components/SecretListView/SecretListView.tsx +++ b/frontend/src/views/SecretMainPage/components/SecretListView/SecretListView.tsx @@ -124,13 +124,15 @@ export const SecretListView = ({ comment, tags, skipMultilineEncoding, - newKey + newKey, + secretId }: Partial<{ value: string; comment: string; tags: string[]; skipMultilineEncoding: boolean; newKey: string; + secretId: string; }> = {} ) => { if (operation === "delete") { @@ -139,7 +141,8 @@ export const SecretListView = ({ workspaceId, secretPath, secretName: key, - type + type, + secretId }); return; } @@ -249,9 +252,9 @@ export const SecretListView = ({ ); const handleSecretDelete = useCallback(async () => { - const { key } = popUp.deleteSecret?.data as DecryptedSecret; + const { key, _id: secretId } = popUp.deleteSecret?.data as DecryptedSecret; try { - await handleSecretOperation("delete", "shared", key); + await handleSecretOperation("delete", "shared", key, { secretId }); queryClient.invalidateQueries( secretKeys.getProjectSecret({ workspaceId, environment, secretPath }) ); diff --git a/frontend/src/views/SecretOverviewPage/SecretOverviewPage.tsx b/frontend/src/views/SecretOverviewPage/SecretOverviewPage.tsx index 34bbc7a6d..58e3f35a7 100644 --- a/frontend/src/views/SecretOverviewPage/SecretOverviewPage.tsx +++ b/frontend/src/views/SecretOverviewPage/SecretOverviewPage.tsx @@ -31,6 +31,7 @@ import { } from "@app/components/v2"; import { useOrganization, useWorkspace } from "@app/context"; import { + useCreateFolder, useCreateSecretV3, useDeleteSecretV3, useGetFoldersByEnv, @@ -104,9 +105,24 @@ export const SecretOverviewPage = () => { const { mutateAsync: createSecretV3 } = useCreateSecretV3(); const { mutateAsync: updateSecretV3 } = useUpdateSecretV3(); const { mutateAsync: deleteSecretV3 } = useDeleteSecretV3(); + const { mutateAsync: createFolder } = useCreateFolder(); const handleSecretCreate = async (env: string, key: string, value: string) => { try { + // create folder if not existing + if (secretPath !== "/") { + const path = secretPath.split("/"); + const directory = path.slice(0, -1).join("/"); + const folderName = path.at(-1); + if (folderName && directory) { + await createFolder({ + workspaceId, + environment: env, + directory, + folderName + }); + } + } await createSecretV3({ environment: env, workspaceId, @@ -154,13 +170,14 @@ export const SecretOverviewPage = () => { } }; - const handleSecretDelete = async (env: string, key: string) => { + const handleSecretDelete = async (env: string, key: string, secretId?: string) => { try { await deleteSecretV3({ environment: env, workspaceId, secretPath, secretName: key, + secretId, type: "shared" }); createNotification({ @@ -188,7 +205,20 @@ export const SecretOverviewPage = () => { }); }; - const handleExploreEnvClick = (slug: string) => { + const handleExploreEnvClick = async (slug: string) => { + if (secretPath !== "/") { + const path = secretPath.split("/"); + const directory = path.slice(0, -1).join("/"); + const folderName = path.at(-1); + if (folderName && directory) { + await createFolder({ + workspaceId, + environment: slug, + directory, + folderName + }); + } + } const query: Record = { ...router.query, env: slug }; const envIndex = userAvailableEnvs.findIndex((el) => slug === el.slug); if (envIndex !== -1) { @@ -335,7 +365,14 @@ export const SecretOverviewPage = () => { query: { id: workspaceId, env: userAvailableEnvs?.[0]?.slug } }} > - + diff --git a/frontend/src/views/SecretOverviewPage/components/SecretOverviewTableRow/SecretEditRow.tsx b/frontend/src/views/SecretOverviewPage/components/SecretOverviewTableRow/SecretEditRow.tsx index 38db3e6b4..06a58172d 100644 --- a/frontend/src/views/SecretOverviewPage/components/SecretOverviewTableRow/SecretEditRow.tsx +++ b/frontend/src/views/SecretOverviewPage/components/SecretOverviewTableRow/SecretEditRow.tsx @@ -12,13 +12,14 @@ import { useToggle } from "@app/hooks"; type Props = { defaultValue?: string | null; secretName: string; + secretId?: string; isCreatable?: boolean; isVisible?: boolean; environment: string; secretPath: string; onSecretCreate: (env: string, key: string, value: string) => Promise; onSecretUpdate: (env: string, key: string, value: string) => Promise; - onSecretDelete: (env: string, key: string) => Promise; + onSecretDelete: (env: string, key: string, secretId?: string) => Promise; }; export const SecretEditRow = ({ @@ -30,7 +31,8 @@ export const SecretEditRow = ({ onSecretDelete, environment, secretPath, - isVisible + isVisible, + secretId }: Props) => { const { handleSubmit, @@ -77,7 +79,7 @@ export const SecretEditRow = ({ const handleDeleteSecret = async () => { setIsDeleting.on(); try { - await onSecretDelete(environment, secretName); + await onSecretDelete(environment, secretName, secretId); reset({ value: undefined }); } finally { setIsDeleting.off(); diff --git a/frontend/src/views/SecretOverviewPage/components/SecretOverviewTableRow/SecretOverviewTableRow.tsx b/frontend/src/views/SecretOverviewPage/components/SecretOverviewTableRow/SecretOverviewTableRow.tsx index ec74dd00a..35a0e734f 100644 --- a/frontend/src/views/SecretOverviewPage/components/SecretOverviewTableRow/SecretOverviewTableRow.tsx +++ b/frontend/src/views/SecretOverviewPage/components/SecretOverviewTableRow/SecretOverviewTableRow.tsx @@ -24,7 +24,7 @@ type Props = { getSecretByKey: (slug: string, key: string) => DecryptedSecret | undefined; onSecretCreate: (env: string, key: string, value: string) => Promise; onSecretUpdate: (env: string, key: string, value: string) => Promise; - onSecretDelete: (env: string, key: string) => Promise; + onSecretDelete: (env: string, key: string, secretId?: string) => Promise; }; export const SecretOverviewTableRow = ({ @@ -149,6 +149,7 @@ export const SecretOverviewTableRow = ({ isVisible={isSecretVisible} secretName={secretKey} defaultValue={secret?.value} + secretId={secret?._id} isCreatable={isCreatable} onSecretDelete={onSecretDelete} onSecretCreate={onSecretCreate}