diff --git a/backend/src/ee/services/audit-log/audit-log-types.ts b/backend/src/ee/services/audit-log/audit-log-types.ts index 67077d4123..f7577b24e3 100644 --- a/backend/src/ee/services/audit-log/audit-log-types.ts +++ b/backend/src/ee/services/audit-log/audit-log-types.ts @@ -45,6 +45,7 @@ export enum EventType { CREATE_SECRETS = "create-secrets", UPDATE_SECRET = "update-secret", UPDATE_SECRETS = "update-secrets", + MOVE_SECRETS = "move-secrets", DELETE_SECRET = "delete-secret", DELETE_SECRETS = "delete-secrets", GET_WORKSPACE_KEY = "get-workspace-key", @@ -240,6 +241,17 @@ interface UpdateSecretBatchEvent { }; } +interface MoveSecretsEvent { + type: EventType.MOVE_SECRETS; + metadata: { + sourceEnvironment: string; + sourceSecretPath: string; + destinationEnvironment: string; + destinationSecretPath: string; + secretIds: string[]; + }; +} + interface DeleteSecretEvent { type: EventType.DELETE_SECRET; metadata: { @@ -1159,6 +1171,7 @@ export type Event = | CreateSecretBatchEvent | UpdateSecretEvent | UpdateSecretBatchEvent + | MoveSecretsEvent | DeleteSecretEvent | DeleteSecretBatchEvent | GetWorkspaceKeyEvent diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index 59c2ab44ec..e0befd7a49 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -712,7 +712,10 @@ export const registerRoutes = async ( secretQueueService, secretImportDAL, projectEnvDAL, - projectBotService + projectBotService, + secretApprovalPolicyService, + secretApprovalRequestDAL, + secretApprovalRequestSecretDAL }); const secretSharingService = secretSharingServiceFactory({ diff --git a/backend/src/server/routes/v3/secret-router.ts b/backend/src/server/routes/v3/secret-router.ts index 910cc3db97..2a4f9b4641 100644 --- a/backend/src/server/routes/v3/secret-router.ts +++ b/backend/src/server/routes/v3/secret-router.ts @@ -1325,6 +1325,61 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => { } }); + server.route({ + method: "POST", + url: "/move", + config: { + rateLimit: secretsLimit + }, + schema: { + body: z.object({ + projectSlug: z.string().trim(), + sourceEnvironment: z.string().trim(), + sourceSecretPath: z.string().trim().default("/").transform(removeTrailingSlash), + destinationEnvironment: z.string().trim(), + destinationSecretPath: z.string().trim().default("/").transform(removeTrailingSlash), + secretIds: z.string().array(), + shouldOverwrite: z.boolean().default(false) + }), + response: { + 200: z.object({ + isSourceUpdated: z.boolean(), + isDestinationUpdated: z.boolean() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const { projectId, isSourceUpdated, isDestinationUpdated } = await server.services.secret.moveSecrets({ + actorId: req.permission.id, + actor: req.permission.type, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + ...req.body + }); + + await server.services.auditLog.createAuditLog({ + projectId, + ...req.auditLogInfo, + event: { + type: EventType.MOVE_SECRETS, + metadata: { + sourceEnvironment: req.body.sourceEnvironment, + sourceSecretPath: req.body.sourceSecretPath, + destinationEnvironment: req.body.destinationEnvironment, + destinationSecretPath: req.body.destinationSecretPath, + secretIds: req.body.secretIds + } + } + }); + + return { + isSourceUpdated, + isDestinationUpdated + }; + } + }); + server.route({ method: "POST", url: "/batch", diff --git a/backend/src/services/secret/secret-service.ts b/backend/src/services/secret/secret-service.ts index cbfccba91c..efe5af03e9 100644 --- a/backend/src/services/secret/secret-service.ts +++ b/backend/src/services/secret/secret-service.ts @@ -11,6 +11,9 @@ import { } from "@app/db/schemas"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; +import { TSecretApprovalPolicyServiceFactory } from "@app/ee/services/secret-approval-policy/secret-approval-policy-service"; +import { TSecretApprovalRequestDALFactory } from "@app/ee/services/secret-approval-request/secret-approval-request-dal"; +import { TSecretApprovalRequestSecretDALFactory } from "@app/ee/services/secret-approval-request/secret-approval-request-secret-dal"; import { TSecretSnapshotServiceFactory } from "@app/ee/services/secret-snapshot/secret-snapshot-service"; import { getConfig } from "@app/lib/config/env"; import { @@ -18,9 +21,10 @@ import { decryptSymmetric128BitHexKeyUTF8, encryptSymmetric128BitHexKeyUTF8 } from "@app/lib/crypto"; -import { BadRequestError } from "@app/lib/errors"; +import { BadRequestError, NotFoundError } from "@app/lib/errors"; import { groupBy, pick } from "@app/lib/fn"; import { logger } from "@app/lib/logger"; +import { alphaNumericNanoId } from "@app/lib/nanoid"; import { ActorType } from "../auth/auth-type"; import { TProjectDALFactory } from "../project/project-dal"; @@ -44,6 +48,7 @@ import { } from "./secret-fns"; import { TSecretQueueFactory } from "./secret-queue"; import { + SecretOperations, TAttachSecretTagsDTO, TBackFillSecretReferencesDTO, TCreateBulkSecretDTO, @@ -59,6 +64,7 @@ import { TGetSecretsDTO, TGetSecretsRawDTO, TGetSecretVersionsDTO, + TMoveSecretsDTO, TUpdateBulkSecretDTO, TUpdateManySecretRawDTO, TUpdateSecretDTO, @@ -84,6 +90,12 @@ type TSecretServiceFactoryDep = { projectBotService: Pick; secretImportDAL: Pick; secretVersionTagDAL: Pick; + secretApprovalPolicyService: Pick; + secretApprovalRequestDAL: Pick; + secretApprovalRequestSecretDAL: Pick< + TSecretApprovalRequestSecretDALFactory, + "insertMany" | "insertApprovalSecretTags" + >; }; export type TSecretServiceFactory = ReturnType; @@ -100,7 +112,10 @@ export const secretServiceFactory = ({ projectDAL, projectBotService, secretImportDAL, - secretVersionTagDAL + secretVersionTagDAL, + secretApprovalPolicyService, + secretApprovalRequestDAL, + secretApprovalRequestSecretDAL }: TSecretServiceFactoryDep) => { const getSecretReference = async (projectId: string) => { // if bot key missing means e2e still exist @@ -1683,6 +1698,393 @@ export const secretServiceFactory = ({ return { message: "Successfully backfilled secret references" }; }; + const moveSecrets = async ({ + sourceEnvironment, + sourceSecretPath, + destinationEnvironment, + destinationSecretPath, + secretIds, + projectSlug, + shouldOverwrite, + actor, + actorId, + actorAuthMethod, + actorOrgId + }: TMoveSecretsDTO) => { + const project = await projectDAL.findProjectBySlug(projectSlug, actorOrgId); + if (!project) { + throw new NotFoundError({ + message: "Project not found." + }); + } + + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + project.id, + actorAuthMethod, + actorOrgId + ); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Delete, + subject(ProjectPermissionSub.Secrets, { environment: sourceEnvironment, secretPath: sourceSecretPath }) + ); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Create, + subject(ProjectPermissionSub.Secrets, { environment: destinationEnvironment, secretPath: destinationSecretPath }) + ); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Edit, + subject(ProjectPermissionSub.Secrets, { environment: destinationEnvironment, secretPath: destinationSecretPath }) + ); + + const botKey = await projectBotService.getBotKey(project.id); + if (!botKey) { + throw new BadRequestError({ message: "Project bot not found", name: "bot_not_found_error" }); + } + + const sourceFolder = await folderDAL.findBySecretPath(project.id, sourceEnvironment, sourceSecretPath); + if (!sourceFolder) { + throw new NotFoundError({ + message: "Source path does not exist." + }); + } + + const destinationFolder = await folderDAL.findBySecretPath( + project.id, + destinationEnvironment, + destinationSecretPath + ); + + if (!destinationFolder) { + throw new NotFoundError({ + message: "Destination path does not exist." + }); + } + + const sourceSecrets = await secretDAL.find({ + type: SecretType.Shared, + $in: { + id: secretIds + } + }); + + if (sourceSecrets.length !== secretIds.length) { + throw new BadRequestError({ + message: "Invalid secrets" + }); + } + + const decryptedSourceSecrets = sourceSecrets.map((secret) => ({ + ...secret, + secretKey: decryptSymmetric128BitHexKeyUTF8({ + ciphertext: secret.secretKeyCiphertext, + iv: secret.secretKeyIV, + tag: secret.secretKeyTag, + key: botKey + }), + secretValue: decryptSymmetric128BitHexKeyUTF8({ + ciphertext: secret.secretValueCiphertext, + iv: secret.secretValueIV, + tag: secret.secretValueTag, + key: botKey + }) + })); + + let isSourceUpdated = false; + let isDestinationUpdated = false; + + // Moving secrets is a two-step process. + await secretDAL.transaction(async (tx) => { + // First step is to create/update the secret in the destination: + const destinationSecretsFromDB = await secretDAL.find( + { + folderId: destinationFolder.id + }, + { tx } + ); + + const decryptedDestinationSecrets = destinationSecretsFromDB.map((secret) => { + return { + ...secret, + secretKey: decryptSymmetric128BitHexKeyUTF8({ + ciphertext: secret.secretKeyCiphertext, + iv: secret.secretKeyIV, + tag: secret.secretKeyTag, + key: botKey + }), + secretValue: decryptSymmetric128BitHexKeyUTF8({ + ciphertext: secret.secretValueCiphertext, + iv: secret.secretValueIV, + tag: secret.secretValueTag, + key: botKey + }) + }; + }); + + const destinationSecretsGroupedByBlindIndex = groupBy( + decryptedDestinationSecrets.filter(({ secretBlindIndex }) => Boolean(secretBlindIndex)), + (i) => i.secretBlindIndex as string + ); + + const locallyCreatedSecrets = decryptedSourceSecrets + .filter(({ secretBlindIndex }) => !destinationSecretsGroupedByBlindIndex[secretBlindIndex as string]?.[0]) + .map((el) => ({ ...el, operation: SecretOperations.Create })); + + const locallyUpdatedSecrets = decryptedSourceSecrets + .filter( + ({ secretBlindIndex, secretKey, secretValue }) => + destinationSecretsGroupedByBlindIndex[secretBlindIndex as string]?.[0] && + // if key or value changed + (destinationSecretsGroupedByBlindIndex[secretBlindIndex as string]?.[0]?.secretKey !== secretKey || + destinationSecretsGroupedByBlindIndex[secretBlindIndex as string]?.[0]?.secretValue !== secretValue) + ) + .map((el) => ({ ...el, operation: SecretOperations.Update })); + + if (locallyUpdatedSecrets.length > 0 && !shouldOverwrite) { + const existingKeys = locallyUpdatedSecrets.map((s) => s.secretKey); + + throw new BadRequestError({ + message: `Failed to move secrets. The following secrets already exist in the destination: ${existingKeys.join( + "," + )}` + }); + } + + const isEmpty = locallyCreatedSecrets.length + locallyUpdatedSecrets.length === 0; + + if (isEmpty) { + throw new BadRequestError({ + message: "Selected secrets already exist in the destination." + }); + } + const destinationFolderPolicy = await secretApprovalPolicyService.getSecretApprovalPolicy( + project.id, + destinationFolder.environment.slug, + destinationFolder.path + ); + + if (destinationFolderPolicy && actor === ActorType.USER) { + // if secret approval policy exists for destination, we create the secret approval request + const localSecretsIds = decryptedDestinationSecrets.map(({ id }) => id); + const latestSecretVersions = await secretVersionDAL.findLatestVersionMany( + destinationFolder.id, + localSecretsIds, + tx + ); + + const approvalRequestDoc = await secretApprovalRequestDAL.create( + { + folderId: destinationFolder.id, + slug: alphaNumericNanoId(), + policyId: destinationFolderPolicy.id, + status: "open", + hasMerged: false, + committerUserId: actorId + }, + tx + ); + + const commits = locallyCreatedSecrets.concat(locallyUpdatedSecrets).map((doc) => { + const { operation } = doc; + const localSecret = destinationSecretsGroupedByBlindIndex[doc.secretBlindIndex as string]?.[0]; + + return { + op: operation, + keyEncoding: doc.keyEncoding, + algorithm: doc.algorithm, + requestId: approvalRequestDoc.id, + metadata: doc.metadata, + secretKeyIV: doc.secretKeyIV, + secretKeyTag: doc.secretKeyTag, + secretKeyCiphertext: doc.secretKeyCiphertext, + secretValueIV: doc.secretValueIV, + secretValueTag: doc.secretValueTag, + secretValueCiphertext: doc.secretValueCiphertext, + secretBlindIndex: doc.secretBlindIndex, + secretCommentIV: doc.secretCommentIV, + secretCommentTag: doc.secretCommentTag, + secretCommentCiphertext: doc.secretCommentCiphertext, + skipMultilineEncoding: doc.skipMultilineEncoding, + // except create operation other two needs the secret id and version id + ...(operation !== SecretOperations.Create + ? { secretId: localSecret.id, secretVersion: latestSecretVersions[localSecret.id].id } + : {}) + }; + }); + await secretApprovalRequestSecretDAL.insertMany(commits, tx); + } else { + // apply changes directly + if (locallyCreatedSecrets.length) { + await fnSecretBulkInsert({ + folderId: destinationFolder.id, + secretVersionDAL, + secretDAL, + tx, + secretTagDAL, + secretVersionTagDAL, + inputSecrets: locallyCreatedSecrets.map((doc) => { + return { + keyEncoding: doc.keyEncoding, + algorithm: doc.algorithm, + type: doc.type, + metadata: doc.metadata, + secretKeyIV: doc.secretKeyIV, + secretKeyTag: doc.secretKeyTag, + secretKeyCiphertext: doc.secretKeyCiphertext, + secretValueIV: doc.secretValueIV, + secretValueTag: doc.secretValueTag, + secretValueCiphertext: doc.secretValueCiphertext, + secretBlindIndex: doc.secretBlindIndex, + secretCommentIV: doc.secretCommentIV, + secretCommentTag: doc.secretCommentTag, + secretCommentCiphertext: doc.secretCommentCiphertext, + skipMultilineEncoding: doc.skipMultilineEncoding + }; + }) + }); + } + if (locallyUpdatedSecrets.length) { + await fnSecretBulkUpdate({ + projectId: project.id, + folderId: destinationFolder.id, + secretVersionDAL, + secretDAL, + tx, + secretTagDAL, + secretVersionTagDAL, + inputSecrets: locallyUpdatedSecrets.map((doc) => { + return { + filter: { + folderId: destinationFolder.id, + id: destinationSecretsGroupedByBlindIndex[doc.secretBlindIndex as string][0].id + }, + data: { + keyEncoding: doc.keyEncoding, + algorithm: doc.algorithm, + type: doc.type, + metadata: doc.metadata, + secretKeyIV: doc.secretKeyIV, + secretKeyTag: doc.secretKeyTag, + secretKeyCiphertext: doc.secretKeyCiphertext, + secretValueIV: doc.secretValueIV, + secretValueTag: doc.secretValueTag, + secretValueCiphertext: doc.secretValueCiphertext, + secretBlindIndex: doc.secretBlindIndex, + secretCommentIV: doc.secretCommentIV, + secretCommentTag: doc.secretCommentTag, + secretCommentCiphertext: doc.secretCommentCiphertext, + skipMultilineEncoding: doc.skipMultilineEncoding + } + }; + }) + }); + } + + isDestinationUpdated = true; + } + + // Next step is to delete the secrets from the source folder: + const sourceSecretsGroupByBlindIndex = groupBy(sourceSecrets, (i) => i.secretBlindIndex as string); + const locallyDeletedSecrets = decryptedSourceSecrets.map((el) => ({ ...el, operation: SecretOperations.Delete })); + + const sourceFolderPolicy = await secretApprovalPolicyService.getSecretApprovalPolicy( + project.id, + sourceFolder.environment.slug, + sourceFolder.path + ); + + if (sourceFolderPolicy && actor === ActorType.USER) { + // if secret approval policy exists for source, we create the secret approval request + const localSecretsIds = decryptedSourceSecrets.map(({ id }) => id); + const latestSecretVersions = await secretVersionDAL.findLatestVersionMany(sourceFolder.id, localSecretsIds, tx); + const approvalRequestDoc = await secretApprovalRequestDAL.create( + { + folderId: sourceFolder.id, + slug: alphaNumericNanoId(), + policyId: sourceFolderPolicy.id, + status: "open", + hasMerged: false, + committerUserId: actorId + }, + tx + ); + + const commits = locallyDeletedSecrets.map((doc) => { + const { operation } = doc; + const localSecret = sourceSecretsGroupByBlindIndex[doc.secretBlindIndex as string]?.[0]; + + return { + op: operation, + keyEncoding: doc.keyEncoding, + algorithm: doc.algorithm, + requestId: approvalRequestDoc.id, + metadata: doc.metadata, + secretKeyIV: doc.secretKeyIV, + secretKeyTag: doc.secretKeyTag, + secretKeyCiphertext: doc.secretKeyCiphertext, + secretValueIV: doc.secretValueIV, + secretValueTag: doc.secretValueTag, + secretValueCiphertext: doc.secretValueCiphertext, + secretBlindIndex: doc.secretBlindIndex, + secretCommentIV: doc.secretCommentIV, + secretCommentTag: doc.secretCommentTag, + secretCommentCiphertext: doc.secretCommentCiphertext, + skipMultilineEncoding: doc.skipMultilineEncoding, + secretId: localSecret.id, + secretVersion: latestSecretVersions[localSecret.id].id + }; + }); + + await secretApprovalRequestSecretDAL.insertMany(commits, tx); + } else { + // if no secret approval policy is present, we delete directly. + await secretDAL.delete( + { + $in: { + id: locallyDeletedSecrets.map(({ id }) => id) + }, + folderId: sourceFolder.id + }, + tx + ); + + isSourceUpdated = true; + } + }); + + if (isDestinationUpdated) { + await snapshotService.performSnapshot(destinationFolder.id); + await secretQueueService.syncSecrets({ + projectId: project.id, + secretPath: destinationFolder.path, + environmentSlug: destinationFolder.environment.slug, + actorId, + actor + }); + } + + if (isSourceUpdated) { + await snapshotService.performSnapshot(sourceFolder.id); + await secretQueueService.syncSecrets({ + projectId: project.id, + secretPath: sourceFolder.path, + environmentSlug: sourceFolder.environment.slug, + actorId, + actor + }); + } + + return { + projectId: project.id, + isSourceUpdated, + isDestinationUpdated + }; + }; + return { attachTags, detachTags, @@ -1703,6 +2105,7 @@ export const secretServiceFactory = ({ updateManySecretsRaw, deleteManySecretsRaw, getSecretVersions, - backfillSecretReferences + backfillSecretReferences, + moveSecrets }; }; diff --git a/backend/src/services/secret/secret-types.ts b/backend/src/services/secret/secret-types.ts index 10df2f2583..d806eab11e 100644 --- a/backend/src/services/secret/secret-types.ts +++ b/backend/src/services/secret/secret-types.ts @@ -397,3 +397,13 @@ export type TSyncSecretsDTO = { // used for import creation to trigger replication pickOnlyImportIds?: string[]; }); + +export type TMoveSecretsDTO = { + projectSlug: string; + sourceEnvironment: string; + sourceSecretPath: string; + destinationEnvironment: string; + destinationSecretPath: string; + secretIds: string[]; + shouldOverwrite: boolean; +} & Omit; diff --git a/frontend/src/hooks/api/secrets/index.ts b/frontend/src/hooks/api/secrets/index.ts index b58e8779aa..737ebf41a3 100644 --- a/frontend/src/hooks/api/secrets/index.ts +++ b/frontend/src/hooks/api/secrets/index.ts @@ -4,6 +4,7 @@ export { useCreateSecretV3, useDeleteSecretBatch, useDeleteSecretV3, + useMoveSecrets, useUpdateSecretBatch, useUpdateSecretV3 } from "./mutations"; diff --git a/frontend/src/hooks/api/secrets/mutations.tsx b/frontend/src/hooks/api/secrets/mutations.tsx index e397c7b550..9c3fd23f0d 100644 --- a/frontend/src/hooks/api/secrets/mutations.tsx +++ b/frontend/src/hooks/api/secrets/mutations.tsx @@ -17,6 +17,7 @@ import { TCreateSecretsV3DTO, TDeleteSecretBatchDTO, TDeleteSecretsV3DTO, + TMoveSecretsDTO, TUpdateSecretBatchDTO, TUpdateSecretsV3DTO } from "./types"; @@ -87,11 +88,11 @@ export const useCreateSecretV3 = ({ const randomBytes = latestFileKey ? decryptAssymmetric({ - ciphertext: latestFileKey.encryptedKey, - nonce: latestFileKey.nonce, - publicKey: latestFileKey.sender.publicKey, - privateKey: PRIVATE_KEY - }) + ciphertext: latestFileKey.encryptedKey, + nonce: latestFileKey.nonce, + publicKey: latestFileKey.sender.publicKey, + privateKey: PRIVATE_KEY + }) : crypto.randomBytes(16).toString("hex"); const reqBody = { @@ -148,11 +149,11 @@ export const useUpdateSecretV3 = ({ const randomBytes = latestFileKey ? decryptAssymmetric({ - ciphertext: latestFileKey.encryptedKey, - nonce: latestFileKey.nonce, - publicKey: latestFileKey.sender.publicKey, - privateKey: PRIVATE_KEY - }) + ciphertext: latestFileKey.encryptedKey, + nonce: latestFileKey.nonce, + publicKey: latestFileKey.sender.publicKey, + privateKey: PRIVATE_KEY + }) : crypto.randomBytes(16).toString("hex"); const reqBody = { @@ -244,11 +245,11 @@ export const useCreateSecretBatch = ({ const PRIVATE_KEY = localStorage.getItem("PRIVATE_KEY") as string; const randomBytes = latestFileKey ? decryptAssymmetric({ - ciphertext: latestFileKey.encryptedKey, - nonce: latestFileKey.nonce, - publicKey: latestFileKey.sender.publicKey, - privateKey: PRIVATE_KEY - }) + ciphertext: latestFileKey.encryptedKey, + nonce: latestFileKey.nonce, + publicKey: latestFileKey.sender.publicKey, + privateKey: PRIVATE_KEY + }) : crypto.randomBytes(16).toString("hex"); const reqBody = { @@ -297,11 +298,11 @@ export const useUpdateSecretBatch = ({ const PRIVATE_KEY = localStorage.getItem("PRIVATE_KEY") as string; const randomBytes = latestFileKey ? decryptAssymmetric({ - ciphertext: latestFileKey.encryptedKey, - nonce: latestFileKey.nonce, - publicKey: latestFileKey.sender.publicKey, - privateKey: PRIVATE_KEY - }) + ciphertext: latestFileKey.encryptedKey, + nonce: latestFileKey.nonce, + publicKey: latestFileKey.sender.publicKey, + privateKey: PRIVATE_KEY + }) : crypto.randomBytes(16).toString("hex"); const reqBody = { @@ -375,6 +376,73 @@ export const useDeleteSecretBatch = ({ }); }; +export const useMoveSecrets = ({ + options +}: { + options?: Omit, "mutationFn">; +} = {}) => { + const queryClient = useQueryClient(); + + return useMutation< + { + isSourceUpdated: boolean; + isDestinationUpdated: boolean; + }, + {}, + TMoveSecretsDTO + >({ + mutationFn: async ({ + sourceEnvironment, + sourceSecretPath, + projectSlug, + destinationEnvironment, + destinationSecretPath, + secretIds, + shouldOverwrite + }) => { + const { data } = await apiRequest.post<{ + isSourceUpdated: boolean; + isDestinationUpdated: boolean; + }>("/api/v3/secrets/move", { + sourceEnvironment, + sourceSecretPath, + projectSlug, + destinationEnvironment, + destinationSecretPath, + secretIds, + shouldOverwrite + }); + + return data; + }, + onSuccess: (_, { projectId, sourceEnvironment, sourceSecretPath }) => { + queryClient.invalidateQueries( + secretKeys.getProjectSecret({ + workspaceId: projectId, + environment: sourceEnvironment, + secretPath: sourceSecretPath + }) + ); + queryClient.invalidateQueries( + secretSnapshotKeys.list({ + environment: sourceEnvironment, + workspaceId: projectId, + directory: sourceSecretPath + }) + ); + queryClient.invalidateQueries( + secretSnapshotKeys.count({ + environment: sourceEnvironment, + workspaceId: projectId, + directory: sourceSecretPath + }) + ); + queryClient.invalidateQueries(secretApprovalRequestKeys.count({ workspaceId: projectId })); + }, + ...options + }); +}; + export const createSecret = async (dto: CreateSecretDTO) => { const { data } = await apiRequest.post(`/api/v3/secrets/${dto.secretKey}`, dto); return data; diff --git a/frontend/src/hooks/api/secrets/types.ts b/frontend/src/hooks/api/secrets/types.ts index 378405c967..de7e1d5031 100644 --- a/frontend/src/hooks/api/secrets/types.ts +++ b/frontend/src/hooks/api/secrets/types.ts @@ -177,6 +177,17 @@ export type TDeleteSecretBatchDTO = { }>; }; +export type TMoveSecretsDTO = { + projectSlug: string; + projectId: string; + sourceEnvironment: string; + sourceSecretPath: string; + destinationEnvironment: string; + destinationSecretPath: string; + secretIds: string[]; + shouldOverwrite: boolean; +}; + export type CreateSecretDTO = { workspaceId: string; environment: string; diff --git a/frontend/src/views/SecretMainPage/components/ActionBar/ActionBar.tsx b/frontend/src/views/SecretMainPage/components/ActionBar/ActionBar.tsx index 627d275395..a4b3eb7c63 100644 --- a/frontend/src/views/SecretMainPage/components/ActionBar/ActionBar.tsx +++ b/frontend/src/views/SecretMainPage/components/ActionBar/ActionBar.tsx @@ -1,7 +1,9 @@ import { useState } from "react"; +import { TypeOptions } from "react-toastify"; import { subject } from "@casl/ability"; import { faAngleDown, + faAnglesRight, faCheckCircle, faChevronRight, faCodeCommit, @@ -46,7 +48,12 @@ import { import { ProjectPermissionActions, ProjectPermissionSub, useSubscription } from "@app/context"; import { interpolateSecrets } from "@app/helpers/secret"; import { usePopUp } from "@app/hooks"; -import { useCreateFolder, useDeleteSecretBatch, useGetUserWsKey } from "@app/hooks/api"; +import { + useCreateFolder, + useDeleteSecretBatch, + useGetUserWsKey, + useMoveSecrets +} from "@app/hooks/api"; import { DecryptedSecret, SecretType, TImportedSecrets, WsTag } from "@app/hooks/api/types"; import { debounce } from "@app/lib/fn/debounce"; @@ -60,6 +67,7 @@ import { Filter, GroupBy } from "../../SecretMainPage.types"; import { CreateDynamicSecretForm } from "./CreateDynamicSecretForm"; import { CreateSecretImportForm } from "./CreateSecretImportForm"; import { FolderForm } from "./FolderForm"; +import { MoveSecretsModal } from "./MoveSecretsModal"; type Props = { secrets?: DecryptedSecret[]; @@ -105,6 +113,7 @@ export const ActionBar = ({ "addDynamicSecret", "addSecretImport", "bulkDeleteSecrets", + "moveSecrets", "misc", "upgradePlan" ] as const); @@ -114,6 +123,7 @@ export const ActionBar = ({ const { mutateAsync: createFolder } = useCreateFolder(); const { mutateAsync: deleteBatchSecretV3 } = useDeleteSecretBatch(); + const { mutateAsync: moveSecrets } = useMoveSecrets(); const { data: decryptFileKey } = useGetUserWsKey(workspaceId); const selectedSecrets = useSelectedSecrets(); @@ -228,6 +238,55 @@ export const ActionBar = ({ } }; + const handleSecretsMove = async ({ + destinationEnvironment, + destinationSecretPath, + shouldOverwrite + }: { + destinationEnvironment: string; + destinationSecretPath: string; + shouldOverwrite: boolean; + }) => { + try { + const secretsToMove = secrets.filter(({ id }) => Boolean(selectedSecrets?.[id])); + const { isDestinationUpdated, isSourceUpdated } = await moveSecrets({ + projectSlug, + shouldOverwrite, + sourceEnvironment: environment, + sourceSecretPath: secretPath, + destinationEnvironment, + destinationSecretPath, + projectId: workspaceId, + secretIds: secretsToMove.map((sec) => sec.id) + }); + + let notificationMessage = ""; + let notificationType: TypeOptions = "info"; + + if (isDestinationUpdated && isSourceUpdated) { + notificationMessage = "Successfully moved selected secrets"; + notificationType = "success"; + } else if (isDestinationUpdated) { + notificationMessage = + "Successfully created secrets in destination. A secret approval request has been generated for the source."; + } else if (isSourceUpdated) { + notificationMessage = "A secret approval request has been generated in the destination"; + } else { + notificationMessage = + "A secret approval request has been generated in both the source and the destination."; + } + + createNotification({ + type: notificationType, + text: notificationMessage + }); + + resetSelectedSecret(); + } catch (error) { + console.error(error); + } + }; + return ( <>
@@ -455,6 +514,25 @@ export const ActionBar = ({
{Object.keys(selectedSecrets).length} Selected
+ + {(isAllowed) => ( + + )} + } - className="ml-4" + className="ml-2" onClick={() => handlePopUpOpen("bulkDeleteSecrets")} isDisabled={!isAllowed} size="xs" @@ -509,6 +587,11 @@ export const ActionBar = ({ onChange={(isOpen) => handlePopUpToggle("bulkDeleteSecrets", isOpen)} onDeleteApproved={handleSecretBulkDelete} /> + {subscription && ( ; + handlePopUpToggle: (popUpName: keyof UsePopUpState<["moveSecrets"]>, state?: boolean) => void; + onMoveApproved: (moveParams: { + destinationEnvironment: string; + destinationSecretPath: string; + shouldOverwrite: boolean; + }) => void; +}; + +const formSchema = z.object({ + environment: z.string().trim(), + secretPath: z + .string() + .trim() + .transform((val) => + typeof val === "string" && val.at(-1) === "/" && val.length > 1 ? val.slice(0, -1) : val + ), + shouldOverwrite: z.boolean().default(false) +}); + +type TFormSchema = z.infer; + +export const MoveSecretsModal = ({ popUp, handlePopUpToggle, onMoveApproved }: Props) => { + const { + handleSubmit, + control, + reset, + watch, + formState: { isSubmitting } + } = useForm({ resolver: zodResolver(formSchema) }); + + const { currentWorkspace } = useWorkspace(); + const environments = currentWorkspace?.environments || []; + const selectedEnvironment = watch("environment"); + + const handleFormSubmit = (data: TFormSchema) => { + onMoveApproved({ + destinationEnvironment: data.environment, + destinationSecretPath: data.secretPath, + shouldOverwrite: data.shouldOverwrite + }); + + handlePopUpToggle("moveSecrets", false); + }; + + return ( + { + reset(); + handlePopUpToggle("moveSecrets", isOpen); + }} + > + +
+ ( + + + + )} + /> + ( + + + + )} + /> + ( + + Overwrite existing secrets + + )} + /> +
+ + +
+ +
+
+ ); +};