From a06dee66f8ea9091598f3ba559e2ff5645da246b Mon Sep 17 00:00:00 2001 From: Sheen Capadngan Date: Tue, 9 Jul 2024 15:20:58 +0800 Subject: [PATCH 1/5] feat: initial logic for moving secrets --- backend/src/server/routes/index.ts | 5 +- backend/src/server/routes/v3/secret-router.ts | 42 ++ backend/src/services/secret/secret-service.ts | 378 +++++++++++++++++- backend/src/services/secret/secret-types.ts | 11 + 4 files changed, 432 insertions(+), 4 deletions(-) diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index bc8b153e03..aec2ef3f7f 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -706,7 +706,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..d12572272a 100644 --- a/backend/src/server/routes/v3/secret-router.ts +++ b/backend/src/server/routes/v3/secret-router.ts @@ -1325,6 +1325,48 @@ 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), + secrets: z + .object({ + id: z.string() + }) + .array() + .min(1) + }), + response: { + 200: z.union([ + z.object({ + secrets: SecretsSchema.omit({ secretBlindIndex: true }).array() + }), + z.object({ approval: SecretApprovalRequestsSchema }).describe("When secret protection policy is enabled") + ]) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + // TODO: publish audit log + return server.services.secret.moveSecrets({ + actorId: req.permission.id, + actor: req.permission.type, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + ...req.body + }); + } + }); + 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..bc31c675ff 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,362 @@ export const secretServiceFactory = ({ return { message: "Successfully backfilled secret references" }; }; + const moveSecrets = async ({ + sourceEnvironment, + sourceSecretPath, + destinationEnvironment, + destinationSecretPath, + secrets, + projectSlug, + 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: secrets.map((secret) => secret.id) + } + }); + + if (sourceSecrets.length !== secrets.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 + }) + })); + + // Moving secrets is a two-step process. + // First step is to create/update the secret in the destination: + const destinationSecretsFromDB = await secretDAL.find({ + folderId: destinationFolder.id + }); + + 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 })); // rewrite update ops to 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 })); // rewrite update ops to create + + const isEmpty = locallyCreatedSecrets.length + locallyUpdatedSecrets.length === 0; + + if (isEmpty) { + throw new BadRequestError({ + message: "No changes were detected between the source and 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); + + await secretApprovalRequestDAL.transaction(async (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 } + : {}) + }; + }); + const approvalCommits = await secretApprovalRequestSecretDAL.insertMany(commits, tx); + return { ...approvalRequestDoc, commits: approvalCommits }; + }); + } else { + // apply changes directly + await secretDAL.transaction(async (tx) => { + 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 + } + }; + }) + }); + } + + await snapshotService.performSnapshot(destinationFolder.id); + await secretQueueService.syncSecrets({ + projectId: project.id, + secretPath: destinationFolder.path, + environmentSlug: destinationFolder.environment.slug, + actorId, + actor + }); + }); + } + + // 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); + + await secretApprovalRequestDAL.transaction(async (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 + }; + }); + const approvalCommits = await secretApprovalRequestSecretDAL.insertMany(commits, tx); + return { ...approvalRequestDoc, commits: approvalCommits }; + }); + } else { + // if no secret approval policy is present, we delete directly. + await secretDAL.delete({ + $in: { + id: locallyDeletedSecrets.map(({ id }) => id) + }, + folderId: sourceFolder.id + }); + + await snapshotService.performSnapshot(sourceFolder.id); + await secretQueueService.syncSecrets({ + projectId: project.id, + secretPath: sourceFolder.path, + environmentSlug: sourceFolder.environment.slug, + actorId, + actor + }); + } + }; + return { attachTags, detachTags, @@ -1703,6 +2074,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..de7b6b7de5 100644 --- a/backend/src/services/secret/secret-types.ts +++ b/backend/src/services/secret/secret-types.ts @@ -397,3 +397,14 @@ 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; + secrets: { + id: string; + }[]; +} & Omit; From 05bf2e46965d9ffb8eeeaafff65eebdee5ed00f8 Mon Sep 17 00:00:00 2001 From: Sheen Capadngan Date: Tue, 9 Jul 2024 16:03:50 +0800 Subject: [PATCH 2/5] made move operation transactional --- backend/src/server/routes/v3/secret-router.ts | 7 +- backend/src/services/secret/secret-service.ts | 217 ++++++++++-------- backend/src/services/secret/secret-types.ts | 4 +- 3 files changed, 118 insertions(+), 110 deletions(-) diff --git a/backend/src/server/routes/v3/secret-router.ts b/backend/src/server/routes/v3/secret-router.ts index d12572272a..8010ff57a1 100644 --- a/backend/src/server/routes/v3/secret-router.ts +++ b/backend/src/server/routes/v3/secret-router.ts @@ -1338,12 +1338,7 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => { sourceSecretPath: z.string().trim().default("/").transform(removeTrailingSlash), destinationEnvironment: z.string().trim(), destinationSecretPath: z.string().trim().default("/").transform(removeTrailingSlash), - secrets: z - .object({ - id: z.string() - }) - .array() - .min(1) + secretIds: z.string().array() }), response: { 200: z.union([ diff --git a/backend/src/services/secret/secret-service.ts b/backend/src/services/secret/secret-service.ts index bc31c675ff..e79e009afc 100644 --- a/backend/src/services/secret/secret-service.ts +++ b/backend/src/services/secret/secret-service.ts @@ -1703,7 +1703,7 @@ export const secretServiceFactory = ({ sourceSecretPath, destinationEnvironment, destinationSecretPath, - secrets, + secretIds, projectSlug, actor, actorId, @@ -1767,11 +1767,11 @@ export const secretServiceFactory = ({ const sourceSecrets = await secretDAL.find({ type: SecretType.Shared, $in: { - id: secrets.map((secret) => secret.id) + id: secretIds } }); - if (sourceSecrets.length !== secrets.length) { + if (sourceSecrets.length !== secretIds.length) { throw new BadRequestError({ message: "Invalid secrets" }); @@ -1793,69 +1793,79 @@ export const secretServiceFactory = ({ }) })); + let isSourceFolderUpdated = false; + let isDestinationFolderUpdated = false; + // Moving secrets is a two-step process. - // First step is to create/update the secret in the destination: - const destinationSecretsFromDB = await secretDAL.find({ - folderId: destinationFolder.id - }); + 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 })); // rewrite update ops to 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 })); // rewrite update ops to create - - const isEmpty = locallyCreatedSecrets.length + locallyUpdatedSecrets.length === 0; - - if (isEmpty) { - throw new BadRequestError({ - message: "No changes were detected between the source and destination." + 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 destinationFolderPolicy = await secretApprovalPolicyService.getSecretApprovalPolicy( - project.id, - destinationFolder.environment.slug, - destinationFolder.path - ); + const destinationSecretsGroupedByBlindIndex = groupBy( + decryptedDestinationSecrets.filter(({ secretBlindIndex }) => Boolean(secretBlindIndex)), + (i) => i.secretBlindIndex as string + ); - 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); + const locallyCreatedSecrets = decryptedSourceSecrets + .filter(({ secretBlindIndex }) => !destinationSecretsGroupedByBlindIndex[secretBlindIndex as string]?.[0]) + .map((el) => ({ ...el, operation: SecretOperations.Create })); // rewrite update ops to 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 })); // rewrite update ops to create + + const isEmpty = locallyCreatedSecrets.length + locallyUpdatedSecrets.length === 0; + + if (isEmpty) { + throw new BadRequestError({ + message: "No changes were detected between the source and 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 + ); - await secretApprovalRequestDAL.transaction(async (tx) => { const approvalRequestDoc = await secretApprovalRequestDAL.create( { folderId: destinationFolder.id, @@ -1895,12 +1905,9 @@ export const secretServiceFactory = ({ : {}) }; }); - const approvalCommits = await secretApprovalRequestSecretDAL.insertMany(commits, tx); - return { ...approvalRequestDoc, commits: approvalCommits }; - }); - } else { - // apply changes directly - await secretDAL.transaction(async (tx) => { + await secretApprovalRequestSecretDAL.insertMany(commits, tx); + } else { + // apply changes directly if (locallyCreatedSecrets.length) { await fnSecretBulkInsert({ folderId: destinationFolder.id, @@ -1967,33 +1974,23 @@ export const secretServiceFactory = ({ }); } - await snapshotService.performSnapshot(destinationFolder.id); - await secretQueueService.syncSecrets({ - projectId: project.id, - secretPath: destinationFolder.path, - environmentSlug: destinationFolder.environment.slug, - actorId, - actor - }); - }); - } + isDestinationFolderUpdated = 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 })); + // 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 - ); + 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); - - await secretApprovalRequestDAL.transaction(async (tx) => { + 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, @@ -2031,18 +2028,36 @@ export const secretServiceFactory = ({ secretVersion: latestSecretVersions[localSecret.id].id }; }); - const approvalCommits = await secretApprovalRequestSecretDAL.insertMany(commits, tx); - return { ...approvalRequestDoc, commits: approvalCommits }; - }); - } else { - // if no secret approval policy is present, we delete directly. - await secretDAL.delete({ - $in: { - id: locallyDeletedSecrets.map(({ id }) => id) - }, - folderId: sourceFolder.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 + ); + + isSourceFolderUpdated = true; + } + }); + + if (isDestinationFolderUpdated) { + await snapshotService.performSnapshot(destinationFolder.id); + await secretQueueService.syncSecrets({ + projectId: project.id, + secretPath: destinationFolder.path, + environmentSlug: destinationFolder.environment.slug, + actorId, + actor + }); + } + + if (isSourceFolderUpdated) { await snapshotService.performSnapshot(sourceFolder.id); await secretQueueService.syncSecrets({ projectId: project.id, diff --git a/backend/src/services/secret/secret-types.ts b/backend/src/services/secret/secret-types.ts index de7b6b7de5..339deb9fba 100644 --- a/backend/src/services/secret/secret-types.ts +++ b/backend/src/services/secret/secret-types.ts @@ -404,7 +404,5 @@ export type TMoveSecretsDTO = { sourceSecretPath: string; destinationEnvironment: string; destinationSecretPath: string; - secrets: { - id: string; - }[]; + secretIds: string[]; } & Omit; From d20ae39f32802e1b32af33eab9b3438c4be93767 Mon Sep 17 00:00:00 2001 From: Sheen Capadngan Date: Tue, 9 Jul 2024 17:48:39 +0800 Subject: [PATCH 3/5] feat: initial move secret integration --- backend/src/services/secret/secret-service.ts | 2 +- frontend/src/hooks/api/secrets/index.ts | 1 + frontend/src/hooks/api/secrets/mutations.tsx | 96 +++++++++++--- frontend/src/hooks/api/secrets/types.ts | 10 ++ .../components/ActionBar/ActionBar.tsx | 68 +++++++++- .../components/ActionBar/MoveSecretsModal.tsx | 120 ++++++++++++++++++ 6 files changed, 274 insertions(+), 23 deletions(-) create mode 100644 frontend/src/views/SecretMainPage/components/ActionBar/MoveSecretsModal.tsx diff --git a/backend/src/services/secret/secret-service.ts b/backend/src/services/secret/secret-service.ts index e79e009afc..56f7f904aa 100644 --- a/backend/src/services/secret/secret-service.ts +++ b/backend/src/services/secret/secret-service.ts @@ -1847,7 +1847,7 @@ export const secretServiceFactory = ({ if (isEmpty) { throw new BadRequestError({ - message: "No changes were detected between the source and destination." + message: "No changes were made. Secrets already exist in the destination." }); } 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..898d99b8d4 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,61 @@ export const useDeleteSecretBatch = ({ }); }; +export const useMoveSecrets = ({ + options +}: { + options?: Omit, "mutationFn">; +} = {}) => { + const queryClient = useQueryClient(); + + return useMutation<{}, {}, TMoveSecretsDTO>({ + mutationFn: async ({ + sourceEnvironment, + sourceSecretPath, + projectSlug, + destinationEnvironment, + destinationSecretPath, + secretIds + }) => { + const { data } = await apiRequest.post("/api/v3/secrets/move", { + sourceEnvironment, + sourceSecretPath, + projectSlug, + destinationEnvironment, + destinationSecretPath, + secretIds + }); + + 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..f10907654f 100644 --- a/frontend/src/hooks/api/secrets/types.ts +++ b/frontend/src/hooks/api/secrets/types.ts @@ -177,6 +177,16 @@ export type TDeleteSecretBatchDTO = { }>; }; +export type TMoveSecretsDTO = { + projectSlug: string; + projectId: string; + sourceEnvironment: string; + sourceSecretPath: string; + destinationEnvironment: string; + destinationSecretPath: string; + secretIds: string[]; +}; + 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..a04d61b36f 100644 --- a/frontend/src/views/SecretMainPage/components/ActionBar/ActionBar.tsx +++ b/frontend/src/views/SecretMainPage/components/ActionBar/ActionBar.tsx @@ -2,6 +2,7 @@ import { useState } from "react"; import { subject } from "@casl/ability"; import { faAngleDown, + faAnglesRight, faCheckCircle, faChevronRight, faCodeCommit, @@ -46,7 +47,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 +66,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 +112,7 @@ export const ActionBar = ({ "addDynamicSecret", "addSecretImport", "bulkDeleteSecrets", + "moveSecrets", "misc", "upgradePlan" ] as const); @@ -114,6 +122,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 +237,37 @@ export const ActionBar = ({ } }; + const handleSecretsMove = async ({ + destinationEnvironment, + destinationSecretPath + }: { + destinationEnvironment: string; + destinationSecretPath: string; + }) => { + try { + const secretsToMove = secrets.filter(({ id }) => Boolean(selectedSecrets?.[id])); + await moveSecrets({ + projectSlug, + sourceEnvironment: environment, + sourceSecretPath: secretPath, + destinationEnvironment, + destinationSecretPath, + projectId: workspaceId, + secretIds: secretsToMove.map((sec) => sec.id) + }); + + createNotification({ + type: "success", + text: "Successfully moved selected secrets" + }); + } catch (error) { + createNotification({ + type: "error", + text: "Error moving selected secrets" + }); + } + }; + return ( <>
@@ -455,6 +495,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 +568,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; + }) => 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 + ) +}); + +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 + }); + + handlePopUpToggle("moveSecrets", false); + }; + + return ( + { + reset(); + handlePopUpToggle("moveSecrets", isOpen); + }} + > + +
+ ( + + + + )} + /> + ( + + + + )} + /> +
+ + +
+ +
+
+ ); +}; From 079e005f49bbc26cb1bbf6bbea3ef14ea696bac1 Mon Sep 17 00:00:00 2001 From: Sheen Capadngan Date: Tue, 9 Jul 2024 21:46:12 +0800 Subject: [PATCH 4/5] misc: added audit log and overwrite feature --- .../ee/services/audit-log/audit-log-types.ts | 13 +++++++ backend/src/server/routes/v3/secret-router.ts | 36 ++++++++++++++----- backend/src/services/secret/secret-service.ts | 36 +++++++++++++------ backend/src/services/secret/secret-types.ts | 1 + frontend/src/hooks/api/secrets/mutations.tsx | 20 ++++++++--- frontend/src/hooks/api/secrets/types.ts | 1 + .../components/ActionBar/ActionBar.tsx | 24 +++++++++++-- .../components/ActionBar/MoveSecretsModal.tsx | 35 +++++++++++++++--- 8 files changed, 136 insertions(+), 30 deletions(-) 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 18930cfe88..92cf6c7014 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", @@ -228,6 +229,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: { @@ -1030,6 +1042,7 @@ export type Event = | CreateSecretBatchEvent | UpdateSecretEvent | UpdateSecretBatchEvent + | MoveSecretsEvent | DeleteSecretEvent | DeleteSecretBatchEvent | GetWorkspaceKeyEvent diff --git a/backend/src/server/routes/v3/secret-router.ts b/backend/src/server/routes/v3/secret-router.ts index 8010ff57a1..2a4f9b4641 100644 --- a/backend/src/server/routes/v3/secret-router.ts +++ b/backend/src/server/routes/v3/secret-router.ts @@ -1338,27 +1338,45 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => { sourceSecretPath: z.string().trim().default("/").transform(removeTrailingSlash), destinationEnvironment: z.string().trim(), destinationSecretPath: z.string().trim().default("/").transform(removeTrailingSlash), - secretIds: z.string().array() + secretIds: z.string().array(), + shouldOverwrite: z.boolean().default(false) }), response: { - 200: z.union([ - z.object({ - secrets: SecretsSchema.omit({ secretBlindIndex: true }).array() - }), - z.object({ approval: SecretApprovalRequestsSchema }).describe("When secret protection policy is enabled") - ]) + 200: z.object({ + isSourceUpdated: z.boolean(), + isDestinationUpdated: z.boolean() + }) } }, onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), handler: async (req) => { - // TODO: publish audit log - return server.services.secret.moveSecrets({ + 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 + }; } }); diff --git a/backend/src/services/secret/secret-service.ts b/backend/src/services/secret/secret-service.ts index 56f7f904aa..efe5af03e9 100644 --- a/backend/src/services/secret/secret-service.ts +++ b/backend/src/services/secret/secret-service.ts @@ -1705,6 +1705,7 @@ export const secretServiceFactory = ({ destinationSecretPath, secretIds, projectSlug, + shouldOverwrite, actor, actorId, actorAuthMethod, @@ -1793,8 +1794,8 @@ export const secretServiceFactory = ({ }) })); - let isSourceFolderUpdated = false; - let isDestinationFolderUpdated = false; + let isSourceUpdated = false; + let isDestinationUpdated = false; // Moving secrets is a two-step process. await secretDAL.transaction(async (tx) => { @@ -1831,7 +1832,7 @@ export const secretServiceFactory = ({ const locallyCreatedSecrets = decryptedSourceSecrets .filter(({ secretBlindIndex }) => !destinationSecretsGroupedByBlindIndex[secretBlindIndex as string]?.[0]) - .map((el) => ({ ...el, operation: SecretOperations.Create })); // rewrite update ops to create + .map((el) => ({ ...el, operation: SecretOperations.Create })); const locallyUpdatedSecrets = decryptedSourceSecrets .filter( @@ -1841,16 +1842,25 @@ export const secretServiceFactory = ({ (destinationSecretsGroupedByBlindIndex[secretBlindIndex as string]?.[0]?.secretKey !== secretKey || destinationSecretsGroupedByBlindIndex[secretBlindIndex as string]?.[0]?.secretValue !== secretValue) ) - .map((el) => ({ ...el, operation: SecretOperations.Update })); // rewrite update ops to create + .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: "No changes were made. Secrets already exist in the destination." + message: "Selected secrets already exist in the destination." }); } - const destinationFolderPolicy = await secretApprovalPolicyService.getSecretApprovalPolicy( project.id, destinationFolder.environment.slug, @@ -1974,7 +1984,7 @@ export const secretServiceFactory = ({ }); } - isDestinationFolderUpdated = true; + isDestinationUpdated = true; } // Next step is to delete the secrets from the source folder: @@ -2042,11 +2052,11 @@ export const secretServiceFactory = ({ tx ); - isSourceFolderUpdated = true; + isSourceUpdated = true; } }); - if (isDestinationFolderUpdated) { + if (isDestinationUpdated) { await snapshotService.performSnapshot(destinationFolder.id); await secretQueueService.syncSecrets({ projectId: project.id, @@ -2057,7 +2067,7 @@ export const secretServiceFactory = ({ }); } - if (isSourceFolderUpdated) { + if (isSourceUpdated) { await snapshotService.performSnapshot(sourceFolder.id); await secretQueueService.syncSecrets({ projectId: project.id, @@ -2067,6 +2077,12 @@ export const secretServiceFactory = ({ actor }); } + + return { + projectId: project.id, + isSourceUpdated, + isDestinationUpdated + }; }; return { diff --git a/backend/src/services/secret/secret-types.ts b/backend/src/services/secret/secret-types.ts index 339deb9fba..d806eab11e 100644 --- a/backend/src/services/secret/secret-types.ts +++ b/backend/src/services/secret/secret-types.ts @@ -405,4 +405,5 @@ export type TMoveSecretsDTO = { destinationEnvironment: string; destinationSecretPath: string; secretIds: string[]; + shouldOverwrite: boolean; } & Omit; diff --git a/frontend/src/hooks/api/secrets/mutations.tsx b/frontend/src/hooks/api/secrets/mutations.tsx index 898d99b8d4..9c3fd23f0d 100644 --- a/frontend/src/hooks/api/secrets/mutations.tsx +++ b/frontend/src/hooks/api/secrets/mutations.tsx @@ -383,22 +383,34 @@ export const useMoveSecrets = ({ } = {}) => { const queryClient = useQueryClient(); - return useMutation<{}, {}, TMoveSecretsDTO>({ + return useMutation< + { + isSourceUpdated: boolean; + isDestinationUpdated: boolean; + }, + {}, + TMoveSecretsDTO + >({ mutationFn: async ({ sourceEnvironment, sourceSecretPath, projectSlug, destinationEnvironment, destinationSecretPath, - secretIds + secretIds, + shouldOverwrite }) => { - const { data } = await apiRequest.post("/api/v3/secrets/move", { + const { data } = await apiRequest.post<{ + isSourceUpdated: boolean; + isDestinationUpdated: boolean; + }>("/api/v3/secrets/move", { sourceEnvironment, sourceSecretPath, projectSlug, destinationEnvironment, destinationSecretPath, - secretIds + secretIds, + shouldOverwrite }); return data; diff --git a/frontend/src/hooks/api/secrets/types.ts b/frontend/src/hooks/api/secrets/types.ts index f10907654f..de7e1d5031 100644 --- a/frontend/src/hooks/api/secrets/types.ts +++ b/frontend/src/hooks/api/secrets/types.ts @@ -185,6 +185,7 @@ export type TMoveSecretsDTO = { destinationEnvironment: string; destinationSecretPath: string; secretIds: string[]; + shouldOverwrite: boolean; }; export type CreateSecretDTO = { diff --git a/frontend/src/views/SecretMainPage/components/ActionBar/ActionBar.tsx b/frontend/src/views/SecretMainPage/components/ActionBar/ActionBar.tsx index a04d61b36f..c8806ac5dc 100644 --- a/frontend/src/views/SecretMainPage/components/ActionBar/ActionBar.tsx +++ b/frontend/src/views/SecretMainPage/components/ActionBar/ActionBar.tsx @@ -239,15 +239,18 @@ export const ActionBar = ({ const handleSecretsMove = async ({ destinationEnvironment, - destinationSecretPath + destinationSecretPath, + shouldOverwrite }: { destinationEnvironment: string; destinationSecretPath: string; + shouldOverwrite: boolean; }) => { try { const secretsToMove = secrets.filter(({ id }) => Boolean(selectedSecrets?.[id])); - await moveSecrets({ + const { isDestinationUpdated, isSourceUpdated } = await moveSecrets({ projectSlug, + shouldOverwrite, sourceEnvironment: environment, sourceSecretPath: secretPath, destinationEnvironment, @@ -256,10 +259,25 @@ export const ActionBar = ({ secretIds: secretsToMove.map((sec) => sec.id) }); + let successMessage = ""; + if (isDestinationUpdated && isSourceUpdated) { + successMessage = "Successfully moved selected secrets"; + } else if (isDestinationUpdated) { + successMessage = + "Successfully created secrets in destination. A secret approval request has been generated for the source."; + } else if (isSourceUpdated) { + successMessage = "A secret approval request has been generated in the destination"; + } else { + successMessage = + "A secret approval request has been generated in both the source and the destination."; + } + createNotification({ type: "success", - text: "Successfully moved selected secrets" + text: successMessage }); + + resetSelectedSecret(); } catch (error) { createNotification({ type: "error", diff --git a/frontend/src/views/SecretMainPage/components/ActionBar/MoveSecretsModal.tsx b/frontend/src/views/SecretMainPage/components/ActionBar/MoveSecretsModal.tsx index 00615b184f..606f23fac8 100644 --- a/frontend/src/views/SecretMainPage/components/ActionBar/MoveSecretsModal.tsx +++ b/frontend/src/views/SecretMainPage/components/ActionBar/MoveSecretsModal.tsx @@ -2,7 +2,15 @@ import { Controller, useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import { z } from "zod"; -import { Button, FormControl, Modal, ModalContent, Select, SelectItem } from "@app/components/v2"; +import { + Button, + Checkbox, + FormControl, + Modal, + ModalContent, + Select, + SelectItem +} from "@app/components/v2"; import { SecretPathInput } from "@app/components/v2/SecretPathInput"; import { useWorkspace } from "@app/context"; import { UsePopUpState } from "@app/hooks/usePopUp"; @@ -13,6 +21,7 @@ type Props = { onMoveApproved: (moveParams: { destinationEnvironment: string; destinationSecretPath: string; + shouldOverwrite: boolean; }) => void; }; @@ -23,7 +32,8 @@ const formSchema = z.object({ .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; @@ -44,7 +54,8 @@ export const MoveSecretsModal = ({ popUp, handlePopUpToggle, onMoveApproved }: P const handleFormSubmit = (data: TFormSchema) => { onMoveApproved({ destinationEnvironment: data.environment, - destinationSecretPath: data.secretPath + destinationSecretPath: data.secretPath, + shouldOverwrite: data.shouldOverwrite }); handlePopUpToggle("moveSecrets", false); @@ -60,7 +71,7 @@ export const MoveSecretsModal = ({ popUp, handlePopUpToggle, onMoveApproved }: P >
)} /> + ( + + Overwrite existing secrets + + )} + />