From c67125361cd06cbd6528999a40e48bd1404f6eb2 Mon Sep 17 00:00:00 2001 From: Akhil Mohan Date: Thu, 2 May 2024 21:11:15 +0530 Subject: [PATCH] feat: added resync replication feature --- .../server/routes/v1/secret-import-router.ts | 43 ++++++++++ .../secret-import/secret-import-service.ts | 79 ++++++++++++++++++- .../secret-import/secret-import-types.ts | 6 ++ frontend/src/hooks/api/secretImports/index.ts | 7 +- .../src/hooks/api/secretImports/mutation.tsx | 20 ++++- frontend/src/hooks/api/secretImports/types.ts | 7 ++ .../ActionBar/CreateSecretImportForm.tsx | 3 +- .../SecretImportListView/SecretImportItem.tsx | 55 ++++++++++++- 8 files changed, 213 insertions(+), 7 deletions(-) diff --git a/backend/src/server/routes/v1/secret-import-router.ts b/backend/src/server/routes/v1/secret-import-router.ts index d540344ca5..50311273c4 100644 --- a/backend/src/server/routes/v1/secret-import-router.ts +++ b/backend/src/server/routes/v1/secret-import-router.ts @@ -211,6 +211,49 @@ export const registerSecretImportRouter = async (server: FastifyZodProvider) => } }); + server.route({ + method: "POST", + url: "/:secretImportId/replication-resync", + config: { + rateLimit: secretsLimit + }, + schema: { + description: "Resync secret replication of secret imports", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + secretImportId: z.string().trim().describe(SECRET_IMPORTS.UPDATE.secretImportId) + }), + body: z.object({ + workspaceId: z.string().trim().describe(SECRET_IMPORTS.UPDATE.workspaceId), + environment: z.string().trim().describe(SECRET_IMPORTS.UPDATE.environment), + path: z.string().trim().default("/").transform(removeTrailingSlash).describe(SECRET_IMPORTS.UPDATE.path) + }), + response: { + 200: z.object({ + message: z.string() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const { message } = await server.services.secretImport.resyncSecretImportReplication({ + actorId: req.permission.id, + actor: req.permission.type, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + id: req.params.secretImportId, + ...req.body, + projectId: req.body.workspaceId + }); + + return { message }; + } + }); + server.route({ method: "GET", url: "/", diff --git a/backend/src/services/secret-import/secret-import-service.ts b/backend/src/services/secret-import/secret-import-service.ts index f002aefbd7..0ff82370a0 100644 --- a/backend/src/services/secret-import/secret-import-service.ts +++ b/backend/src/services/secret-import/secret-import-service.ts @@ -1,5 +1,6 @@ import { ForbiddenError, subject } from "@casl/ability"; +import { TableName } from "@app/db/schemas"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; import { BadRequestError } from "@app/lib/errors"; @@ -17,6 +18,7 @@ import { TDeleteSecretImportDTO, TGetSecretImportsDTO, TGetSecretsFromImportDTO, + TResyncSecretImportReplicationDTO, TUpdateSecretImportDTO } from "./secret-import-types"; @@ -109,17 +111,17 @@ export const secretImportServiceFactory = ({ ); }); - if (secImport.isReplication) { + if (secImport.isReplication && sourceFolder && membership) { const importedSecrets = await secretDAL.find({ folderId: sourceFolder?.id }); await secretQueueService.replicateSecrets({ secretPath: secImport.importPath, projectId, environmentSlug: importEnv.slug, pickOnlyImportIds: [secImport.id], - folderId: sourceFolder?.id as string, + folderId: sourceFolder.id, secrets: importedSecrets.map(({ id, version }) => ({ operation: SecretOperations.Create, version, id })), // TODO(akhilmhdh): approval based replication this will fail for identity - membershipId: membership?.id as string, + membershipId: membership.id, environmentId: importEnv.id }); } else { @@ -247,6 +249,76 @@ export const secretImportServiceFactory = ({ return secImport; }; + const resyncSecretImportReplication = async ({ + environment, + actor, + actorId, + actorOrgId, + actorAuthMethod, + projectId, + path, + id: secretImportDocId + }: TResyncSecretImportReplicationDTO) => { + const { permission, membership } = await permissionService.getProjectPermission( + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId + ); + + // check if user has permission to import into destination path + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Create, + subject(ProjectPermissionSub.Secrets, { environment, secretPath: path }) + ); + + const folder = await folderDAL.findBySecretPath(projectId, environment, path); + if (!folder) throw new BadRequestError({ message: "Folder not found", name: "Update import" }); + + const [secretImportDoc] = await secretImportDAL.find({ + folderId: folder.id, + [`${TableName.SecretImport}.id` as "id"]: secretImportDocId + }); + if (!secretImportDoc) throw new BadRequestError({ message: "Failed to find secret import" }); + + if (!secretImportDoc.isReplication) throw new BadRequestError({ message: "Import is not in replication mode" }); + + // check if user has permission to import from target path + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Create, + subject(ProjectPermissionSub.Secrets, { + environment: secretImportDoc.importEnv.slug, + secretPath: secretImportDoc.importPath + }) + ); + + await projectDAL.checkProjectUpgradeStatus(projectId); + + const sourceFolder = await folderDAL.findBySecretPath( + projectId, + secretImportDoc.importEnv.slug, + secretImportDoc.importPath + ); + + const importedSecrets = await secretDAL.find({ folderId: sourceFolder?.id }); + if (membership && sourceFolder) { + await secretQueueService.replicateSecrets({ + secretPath: secretImportDoc.importPath, + projectId, + environmentSlug: secretImportDoc.importEnv.slug, + pickOnlyImportIds: [secretImportDoc.id], + folderId: sourceFolder.id, + secrets: importedSecrets.map(({ id, version }) => ({ operation: SecretOperations.Create, version, id })), + // TODO(akhilmhdh): approval based replication this will fail for identity + membershipId: membership.id, + environmentId: secretImportDoc.importEnv.id + }); + } + + return { message: "replication started" }; + }; + const getImports = async ({ path, environment, @@ -319,6 +391,7 @@ export const secretImportServiceFactory = ({ deleteImport, getImports, getSecretsFromImports, + resyncSecretImportReplication, fnSecretsFromImports }; }; diff --git a/backend/src/services/secret-import/secret-import-types.ts b/backend/src/services/secret-import/secret-import-types.ts index 0dca0c3061..01847738be 100644 --- a/backend/src/services/secret-import/secret-import-types.ts +++ b/backend/src/services/secret-import/secret-import-types.ts @@ -17,6 +17,12 @@ export type TUpdateSecretImportDTO = { data: Partial<{ environment: string; path: string; position: number }>; } & TProjectPermission; +export type TResyncSecretImportReplicationDTO = { + environment: string; + path: string; + id: string; +} & TProjectPermission; + export type TDeleteSecretImportDTO = { environment: string; path: string; diff --git a/frontend/src/hooks/api/secretImports/index.ts b/frontend/src/hooks/api/secretImports/index.ts index f30506b6b1..fed0f13d4a 100644 --- a/frontend/src/hooks/api/secretImports/index.ts +++ b/frontend/src/hooks/api/secretImports/index.ts @@ -1,4 +1,9 @@ -export { useCreateSecretImport, useDeleteSecretImport, useUpdateSecretImport } from "./mutation"; +export { + useCreateSecretImport, + useDeleteSecretImport, + useResyncSecretReplication, + useUpdateSecretImport +} from "./mutation"; export { useGetImportedFoldersByEnv, useGetImportedSecretsAllEnvs, diff --git a/frontend/src/hooks/api/secretImports/mutation.tsx b/frontend/src/hooks/api/secretImports/mutation.tsx index 0bee77ae8b..04f1f01e6a 100644 --- a/frontend/src/hooks/api/secretImports/mutation.tsx +++ b/frontend/src/hooks/api/secretImports/mutation.tsx @@ -3,7 +3,12 @@ import { useMutation, useQueryClient } from "@tanstack/react-query"; import { apiRequest } from "@app/config/request"; import { secretImportKeys } from "./queries"; -import { TCreateSecretImportDTO, TDeleteSecretImportDTO, TUpdateSecretImportDTO } from "./types"; +import { + TCreateSecretImportDTO, + TDeleteSecretImportDTO, + TResyncSecretReplicationDTO, + TUpdateSecretImportDTO +} from "./types"; export const useCreateSecretImport = () => { const queryClient = useQueryClient(); @@ -54,6 +59,19 @@ export const useUpdateSecretImport = () => { }); }; +export const useResyncSecretReplication = () => { + return useMutation<{}, {}, TResyncSecretReplicationDTO>({ + mutationFn: async ({ environment, projectId, path, id }) => { + const { data } = await apiRequest.post(`/api/v1/secret-imports/${id}/replication-resync`, { + environment, + path, + workspaceId: projectId + }); + return data; + } + }); +}; + export const useDeleteSecretImport = () => { const queryClient = useQueryClient(); diff --git a/frontend/src/hooks/api/secretImports/types.ts b/frontend/src/hooks/api/secretImports/types.ts index 8aafb0e881..b9c0ddf576 100644 --- a/frontend/src/hooks/api/secretImports/types.ts +++ b/frontend/src/hooks/api/secretImports/types.ts @@ -76,6 +76,13 @@ export type TUpdateSecretImportDTO = { }>; }; +export type TResyncSecretReplicationDTO = { + id: string; + projectId: string; + environment: string; + path?: string; +}; + export type TDeleteSecretImportDTO = { id: string; projectId: string; diff --git a/frontend/src/views/SecretMainPage/components/ActionBar/CreateSecretImportForm.tsx b/frontend/src/views/SecretMainPage/components/ActionBar/CreateSecretImportForm.tsx index 9afc73b06c..0d6aee8c28 100644 --- a/frontend/src/views/SecretMainPage/components/ActionBar/CreateSecretImportForm.tsx +++ b/frontend/src/views/SecretMainPage/components/ActionBar/CreateSecretImportForm.tsx @@ -81,7 +81,8 @@ export const CreateSecretImportForm = ({ reset(); createNotification({ type: "success", - text: "Successfully linked" + text: `Successfully linked.${isReplication ? " Kindly refresh the board to see changes." : "" + }` }); } catch (err) { console.error(err); diff --git a/frontend/src/views/SecretMainPage/components/SecretImportListView/SecretImportItem.tsx b/frontend/src/views/SecretMainPage/components/SecretImportListView/SecretImportItem.tsx index fa3897e4d3..a88214a4e1 100644 --- a/frontend/src/views/SecretMainPage/components/SecretImportListView/SecretImportItem.tsx +++ b/frontend/src/views/SecretMainPage/components/SecretImportListView/SecretImportItem.tsx @@ -6,14 +6,18 @@ import { faFileImport, faFolder, faKey, + faRotate, faUpDown } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { twMerge } from "tailwind-merge"; +import { createNotification } from "@app/components/notifications"; import { ProjectPermissionCan } from "@app/components/permissions"; import { EmptyState, IconButton, SecretInput, TableContainer, Tag } from "@app/components/v2"; -import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context"; +import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context"; import { useToggle } from "@app/hooks"; +import { useResyncSecretReplication } from "@app/hooks/api"; type Props = { onDelete: () => void; @@ -60,10 +64,12 @@ export const SecretImportItem = ({ secretPath, environment }: Props) => { + const { currentWorkspace } = useWorkspace(); const [isExpanded, setIsExpanded] = useToggle(); const { attributes, listeners, transform, transition, setNodeRef, isDragging } = useSortable({ id }); + const resyncSecretReplication = useResyncSecretReplication(); useEffect(() => { const filteredSecrets = importedSecrets.filter((secret) => @@ -88,6 +94,28 @@ export const SecretImportItem = ({ transition }; + const handleResyncSecretReplication = async () => { + if (resyncSecretReplication.isLoading) return; + try { + await resyncSecretReplication.mutateAsync({ + id, + environment, + path: secretPath, + projectId: currentWorkspace?.id || "" + }); + createNotification({ + text: "Kindly refresh the board to see changes.", + type: "success" + }); + } catch (error) { + console.error(error); + createNotification({ + text: "Failed to resync replication", + type: "error" + }); + } + }; + return ( <>
+
+ + {(isAllowed) => ( + + + + )} + +