diff --git a/backend/e2e-test/routes/v3/secrets-v2.spec.ts b/backend/e2e-test/routes/v3/secrets-v2.spec.ts index dc02587cdc..a6f4475e01 100644 --- a/backend/e2e-test/routes/v3/secrets-v2.spec.ts +++ b/backend/e2e-test/routes/v3/secrets-v2.spec.ts @@ -535,6 +535,107 @@ describe.each([{ auth: AuthMode.JWT }, { auth: AuthMode.IDENTITY_ACCESS_TOKEN }] ); }); + test.each(secretTestCases)("Bulk upsert secrets in path $path", async ({ secret, path }) => { + const updateSharedSecRes = await testServer.inject({ + method: "PATCH", + url: `/api/v3/secrets/batch/raw`, + headers: { + authorization: `Bearer ${authToken}` + }, + body: { + workspaceId: seedData1.projectV3.id, + environment: seedData1.environment.slug, + secretPath: path, + mode: "upsert", + secrets: Array.from(Array(5)).map((_e, i) => ({ + secretKey: `BULK-${secret.key}-${i + 1}`, + secretValue: "update-value", + secretComment: secret.comment + })) + } + }); + expect(updateSharedSecRes.statusCode).toBe(200); + const updateSharedSecPayload = JSON.parse(updateSharedSecRes.payload); + expect(updateSharedSecPayload).toHaveProperty("secrets"); + + // bulk ones should exist + const secrets = await getSecrets(seedData1.environment.slug, path); + expect(secrets).toEqual( + expect.arrayContaining( + Array.from(Array(5)).map((_e, i) => + expect.objectContaining({ + secretKey: `BULK-${secret.key}-${i + 1}`, + secretValue: "update-value", + type: SecretType.Shared + }) + ) + ) + ); + await Promise.all( + Array.from(Array(5)).map((_e, i) => deleteSecret({ path, key: `BULK-${secret.key}-${i + 1}` })) + ); + }); + + test("Bulk upsert secrets in path multiple paths", async () => { + const firstBatchSecrets = Array.from(Array(5)).map((_e, i) => ({ + secretKey: `BULK-KEY-${secretTestCases[0].secret.key}-${i + 1}`, + secretValue: "update-value", + secretComment: "comment", + secretPath: secretTestCases[0].path + })); + const secondBatchSecrets = Array.from(Array(5)).map((_e, i) => ({ + secretKey: `BULK-KEY-${secretTestCases[1].secret.key}-${i + 1}`, + secretValue: "update-value", + secretComment: "comment", + secretPath: secretTestCases[1].path + })); + const testSecrets = [...firstBatchSecrets, ...secondBatchSecrets]; + + const updateSharedSecRes = await testServer.inject({ + method: "PATCH", + url: `/api/v3/secrets/batch/raw`, + headers: { + authorization: `Bearer ${authToken}` + }, + body: { + workspaceId: seedData1.projectV3.id, + environment: seedData1.environment.slug, + mode: "upsert", + secrets: testSecrets + } + }); + expect(updateSharedSecRes.statusCode).toBe(200); + const updateSharedSecPayload = JSON.parse(updateSharedSecRes.payload); + expect(updateSharedSecPayload).toHaveProperty("secrets"); + + // bulk ones should exist + const firstBatchSecretsOnInfisical = await getSecrets(seedData1.environment.slug, secretTestCases[0].path); + expect(firstBatchSecretsOnInfisical).toEqual( + expect.arrayContaining( + firstBatchSecrets.map((el) => + expect.objectContaining({ + secretKey: el.secretKey, + secretValue: "update-value", + type: SecretType.Shared + }) + ) + ) + ); + const secondBatchSecretsOnInfisical = await getSecrets(seedData1.environment.slug, secretTestCases[1].path); + expect(secondBatchSecretsOnInfisical).toEqual( + expect.arrayContaining( + secondBatchSecrets.map((el) => + expect.objectContaining({ + secretKey: el.secretKey, + secretValue: "update-value", + type: SecretType.Shared + }) + ) + ) + ); + await Promise.all(testSecrets.map((el) => deleteSecret({ path: el.secretPath, key: el.secretKey }))); + }); + test.each(secretTestCases)("Bulk delete secrets in path $path", async ({ secret, path }) => { await Promise.all( Array.from(Array(5)).map((_e, i) => createSecret({ ...secret, key: `BULK-${secret.key}-${i + 1}`, path })) 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 1bb58764c1..e124c1f451 100644 --- a/backend/src/ee/services/audit-log/audit-log-types.ts +++ b/backend/src/ee/services/audit-log/audit-log-types.ts @@ -352,6 +352,7 @@ interface CreateSecretBatchEvent { secrets: Array<{ secretId: string; secretKey: string; + secretPath?: string; secretVersion: number; secretMetadata?: TSecretMetadata; }>; @@ -374,8 +375,14 @@ interface UpdateSecretBatchEvent { type: EventType.UPDATE_SECRETS; metadata: { environment: string; - secretPath: string; - secrets: Array<{ secretId: string; secretKey: string; secretVersion: number; secretMetadata?: TSecretMetadata }>; + secretPath?: string; + secrets: Array<{ + secretId: string; + secretKey: string; + secretVersion: number; + secretMetadata?: TSecretMetadata; + secretPath?: string; + }>; }; } diff --git a/backend/src/lib/api-docs/constants.ts b/backend/src/lib/api-docs/constants.ts index 23f783792d..7e85c2031f 100644 --- a/backend/src/lib/api-docs/constants.ts +++ b/backend/src/lib/api-docs/constants.ts @@ -721,7 +721,8 @@ export const RAW_SECRETS = { secretName: "The name of the secret to update.", secretComment: "Update comment to the secret.", environment: "The slug of the environment where the secret is located.", - secretPath: "The path of the secret to update.", + mode: "Defines how the system should handle missing secrets during an update.", + secretPath: "The default path for secrets to update or upsert, if not provided in the secret details.", secretValue: "The new value of the secret.", skipMultilineEncoding: "Skip multiline encoding for the secret value.", type: "The type of the secret to update.", diff --git a/backend/src/server/routes/v3/secret-router.ts b/backend/src/server/routes/v3/secret-router.ts index af1747ed6c..5dbe3c93b6 100644 --- a/backend/src/server/routes/v3/secret-router.ts +++ b/backend/src/server/routes/v3/secret-router.ts @@ -20,6 +20,7 @@ import { ActorType, AuthMode } from "@app/services/auth/auth-type"; import { ProjectFilterType } from "@app/services/project/project-types"; import { ResourceMetadataSchema } from "@app/services/resource-metadata/resource-metadata-schema"; import { SecretOperations, SecretProtectionType } from "@app/services/secret/secret-types"; +import { SecretUpdateMode } from "@app/services/secret-v2-bridge/secret-v2-bridge-types"; import { PostHogEventTypes } from "@app/services/telemetry/telemetry-types"; import { secretRawSchema } from "../sanitizedSchemas"; @@ -2030,6 +2031,11 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => { .default("/") .transform(removeTrailingSlash) .describe(RAW_SECRETS.UPDATE.secretPath), + mode: z + .nativeEnum(SecretUpdateMode) + .optional() + .default(SecretUpdateMode.FailOnNotFound) + .describe(RAW_SECRETS.UPDATE.mode), secrets: z .object({ secretKey: SecretNameSchema.describe(RAW_SECRETS.UPDATE.secretName), @@ -2037,6 +2043,12 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => { .string() .transform((val) => (val.at(-1) === "\n" ? `${val.trim()}\n` : val.trim())) .describe(RAW_SECRETS.UPDATE.secretValue), + secretPath: z + .string() + .trim() + .transform(removeTrailingSlash) + .optional() + .describe(RAW_SECRETS.UPDATE.secretPath), secretComment: z.string().trim().optional().describe(RAW_SECRETS.UPDATE.secretComment), skipMultilineEncoding: z.boolean().optional().describe(RAW_SECRETS.UPDATE.skipMultilineEncoding), newSecretName: SecretNameSchema.optional().describe(RAW_SECRETS.UPDATE.newSecretName), @@ -2073,7 +2085,8 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => { environment, projectSlug, projectId: req.body.workspaceId, - secrets: inputSecrets + secrets: inputSecrets, + mode: req.body.mode }); if (secretOperation.type === SecretProtectionType.Approval) { return { approval: secretOperation.approval }; @@ -2092,15 +2105,39 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => { metadata: { environment: req.body.environment, secretPath: req.body.secretPath, - secrets: secrets.map((secret) => ({ - secretId: secret.id, - secretKey: secret.secretKey, - secretVersion: secret.version, - secretMetadata: secretMetadataMap.get(secret.secretKey) - })) + secrets: secrets + .filter((el) => el.version > 1) + .map((secret) => ({ + secretId: secret.id, + secretPath: secret.secretPath, + secretKey: secret.secretKey, + secretVersion: secret.version, + secretMetadata: secretMetadataMap.get(secret.secretKey) + })) } } }); + const createdSecrets = secrets.filter((el) => el.version === 1); + if (createdSecrets.length) { + await server.services.auditLog.createAuditLog({ + projectId: secrets[0].workspace, + ...req.auditLogInfo, + event: { + type: EventType.CREATE_SECRETS, + metadata: { + environment: req.body.environment, + secretPath: req.body.secretPath, + secrets: createdSecrets.map((secret) => ({ + secretId: secret.id, + secretPath: secret.secretPath, + secretKey: secret.secretKey, + secretVersion: secret.version, + secretMetadata: secretMetadataMap.get(secret.secretKey) + })) + } + } + }); + } await server.services.telemetry.sendPostHogEvents({ event: PostHogEventTypes.SecretUpdated, diff --git a/backend/src/services/secret-v2-bridge/secret-v2-bridge-service.ts b/backend/src/services/secret-v2-bridge/secret-v2-bridge-service.ts index b03a7a090f..0ffb0ea4cd 100644 --- a/backend/src/services/secret-v2-bridge/secret-v2-bridge-service.ts +++ b/backend/src/services/secret-v2-bridge/secret-v2-bridge-service.ts @@ -1,7 +1,15 @@ import { ForbiddenError, PureAbility, subject } from "@casl/ability"; +import { Knex } from "knex"; import { z } from "zod"; -import { ActionProjectType, ProjectMembershipRole, SecretsV2Schema, SecretType, TableName } from "@app/db/schemas"; +import { + ActionProjectType, + ProjectMembershipRole, + SecretsV2Schema, + SecretType, + TableName, + TSecretsV2 +} 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"; @@ -36,6 +44,7 @@ import { } from "./secret-v2-bridge-fns"; import { SecretOperations, + SecretUpdateMode, TBackFillSecretReferencesDTO, TCreateManySecretDTO, TCreateSecretDTO, @@ -103,12 +112,13 @@ export const secretV2BridgeServiceFactory = ({ const $validateSecretReferences = async ( projectId: string, permission: PureAbility, - references: ReturnType["nestedReferences"] + references: ReturnType["nestedReferences"], + tx?: Knex ) => { if (!references.length) return; const uniqueReferenceEnvironmentSlugs = Array.from(new Set(references.map((el) => el.environment))); - const referencesEnvironments = await projectEnvDAL.findBySlugs(projectId, uniqueReferenceEnvironmentSlugs); + const referencesEnvironments = await projectEnvDAL.findBySlugs(projectId, uniqueReferenceEnvironmentSlugs, tx); if (referencesEnvironments.length !== uniqueReferenceEnvironmentSlugs.length) throw new BadRequestError({ message: `Referenced environment not found. Missing ${diff( @@ -122,36 +132,41 @@ export const secretV2BridgeServiceFactory = ({ references.map((el) => ({ secretPath: el.secretPath, envId: referencesEnvironmentGroupBySlug[el.environment][0].id - })) + })), + tx ); const referencesFolderGroupByPath = groupBy(referredFolders.filter(Boolean), (i) => `${i?.envId}-${i?.path}`); - const referredSecrets = await secretDAL.find({ - $complex: { - operator: "or", - value: references.map((el) => { - const folderId = - referencesFolderGroupByPath[`${referencesEnvironmentGroupBySlug[el.environment][0].id}-${el.secretPath}`][0] - ?.id; - if (!folderId) throw new BadRequestError({ message: `Referenced path ${el.secretPath} doesn't exist` }); - - return { - operator: "and", - value: [ - { - operator: "eq", - field: "folderId", - value: folderId - }, - { - operator: "eq", - field: `${TableName.SecretV2}.key` as "key", - value: el.secretKey - } - ] - }; - }) - } - }); + const referredSecrets = await secretDAL.find( + { + $complex: { + operator: "or", + value: references.map((el) => { + const folderId = + referencesFolderGroupByPath[ + `${referencesEnvironmentGroupBySlug[el.environment][0].id}-${el.secretPath}` + ][0]?.id; + if (!folderId) throw new BadRequestError({ message: `Referenced path ${el.secretPath} doesn't exist` }); + + return { + operator: "and", + value: [ + { + operator: "eq", + field: "folderId", + value: folderId + }, + { + operator: "eq", + field: `${TableName.SecretV2}.key` as "key", + value: el.secretKey + } + ] + }; + }) + } + }, + { tx } + ); if ( referredSecrets.length !== @@ -1245,8 +1260,9 @@ export const secretV2BridgeServiceFactory = ({ actorAuthMethod, environment, projectId, - secretPath, - secrets: inputSecrets + secretPath: defaultSecretPath = "/", + secrets: inputSecrets, + mode: updateMode }: TUpdateManySecretDTO) => { const { permission } = await permissionService.getProjectPermission({ actor, @@ -1257,196 +1273,280 @@ export const secretV2BridgeServiceFactory = ({ actionProjectType: ActionProjectType.SecretManager }); - const folder = await folderDAL.findBySecretPath(projectId, environment, secretPath); - if (!folder) + const secretsToUpdateGroupByPath = groupBy(inputSecrets, (el) => el.secretPath || defaultSecretPath); + const projectEnvironment = await projectEnvDAL.findOne({ projectId, slug: environment }); + if (!projectEnvironment) { throw new NotFoundError({ - message: `Folder with path '${secretPath}' in environment with slug '${environment}' not found`, + message: `Environment with slug '${environment}' in project with ID '${projectId}' not found` + }); + } + + const folders = await folderDAL.findByManySecretPath( + Object.keys(secretsToUpdateGroupByPath).map((el) => ({ envId: projectEnvironment.id, secretPath: el })) + ); + if (folders.length !== Object.keys(secretsToUpdateGroupByPath).length) + throw new NotFoundError({ + message: `Folder with path '${null}' in environment with slug '${environment}' not found`, name: "UpdateManySecret" }); - const folderId = folder.id; - const secretsToUpdate = await secretDAL.find({ - folderId, - $complex: { - operator: "and", - value: [ + const { encryptor: secretManagerEncryptor, decryptor: secretManagerDecryptor } = + await kmsService.createCipherPairWithDataKey({ type: KmsDataKey.SecretManager, projectId }); + + const updatedSecrets: Array = []; + await secretDAL.transaction(async (tx) => { + for await (const folder of folders) { + if (!folder) throw new NotFoundError({ message: "Folder not found" }); + + const folderId = folder.id; + const secretPath = folder.path; + let secretsToUpdate = secretsToUpdateGroupByPath[secretPath]; + const secretsToUpdateInDB = await secretDAL.find( { - operator: "or", - value: inputSecrets.map((el) => ({ + folderId, + $complex: { operator: "and", value: [ { - operator: "eq", - field: `${TableName.SecretV2}.key` as "key", - value: el.secretKey - }, - { - operator: "eq", - field: "type", - value: SecretType.Shared + operator: "or", + value: secretsToUpdate.map((el) => ({ + operator: "and", + value: [ + { + operator: "eq", + field: `${TableName.SecretV2}.key` as "key", + value: el.secretKey + }, + { + operator: "eq", + field: "type", + value: SecretType.Shared + } + ] + })) } ] - })) - } - ] - } - }); - if (secretsToUpdate.length !== inputSecrets.length) { - const secretsToUpdateNames = secretsToUpdate.map((secret) => secret.key); - const invalidSecrets = inputSecrets.filter((secret) => !secretsToUpdateNames.includes(secret.secretKey)); - throw new NotFoundError({ - message: `Secret does not exist: ${invalidSecrets.map((el) => el.secretKey).join(",")}` - }); - } - const secretsToUpdateInDBGroupedByKey = groupBy(secretsToUpdate, (i) => i.key); + } + }, + { tx } + ); + if (secretsToUpdateInDB.length !== secretsToUpdate.length && updateMode === SecretUpdateMode.FailOnNotFound) + throw new NotFoundError({ + message: `Secret does not exist: ${diff( + secretsToUpdate.map((el) => el.secretKey), + secretsToUpdateInDB.map((el) => el.key) + ).join(", ")} in path ${folder.path}` + }); - secretsToUpdate.forEach((el) => { - ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionActions.Edit, - subject(ProjectPermissionSub.Secrets, { - environment, - secretPath, - secretName: el.key, - secretTags: el.tags.map((i) => i.slug) - }) - ); - }); + const secretsToUpdateInDBGroupedByKey = groupBy(secretsToUpdateInDB, (i) => i.key); + const secretsToCreate = secretsToUpdate.filter((el) => !secretsToUpdateInDBGroupedByKey?.[el.secretKey]); + secretsToUpdate = secretsToUpdate.filter((el) => secretsToUpdateInDBGroupedByKey?.[el.secretKey]); - // get all tags - const sanitizedTagIds = inputSecrets.flatMap(({ tagIds = [] }) => tagIds); - const tags = sanitizedTagIds.length ? await secretTagDAL.findManyTagsById(projectId, sanitizedTagIds) : []; - if (tags.length !== sanitizedTagIds.length) throw new NotFoundError({ message: "Tag not found" }); - const tagsGroupByID = groupBy(tags, (i) => i.id); + secretsToUpdateInDB.forEach((el) => { + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Edit, + subject(ProjectPermissionSub.Secrets, { + environment, + secretPath, + secretName: el.key, + secretTags: el.tags.map((i) => i.slug) + }) + ); + }); - // check again to avoid non authorized tags are removed - inputSecrets.forEach((el) => { - ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionActions.Edit, - subject(ProjectPermissionSub.Secrets, { - environment, - secretPath, - secretName: el.secretKey, - secretTags: (el.tagIds || []).map((i) => tagsGroupByID[i][0].slug) - }) - ); - }); + // get all tags + const sanitizedTagIds = secretsToUpdate.flatMap(({ tagIds = [] }) => tagIds); + const tags = sanitizedTagIds.length ? await secretTagDAL.findManyTagsById(projectId, sanitizedTagIds, tx) : []; + if (tags.length !== sanitizedTagIds.length) throw new NotFoundError({ message: "Tag not found" }); + const tagsGroupByID = groupBy(tags, (i) => i.id); + + // check create permission allowed in upsert mode + if (updateMode === SecretUpdateMode.Upsert) { + secretsToCreate.forEach((el) => { + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Create, + subject(ProjectPermissionSub.Secrets, { + environment, + secretPath, + secretName: el.secretKey, + secretTags: (el.tagIds || []).map((i) => tagsGroupByID[i][0].slug) + }) + ); + }); + } - // now find any secret that needs to update its name - // same process as above - const secretsWithNewName = inputSecrets.filter(({ newSecretName }) => Boolean(newSecretName)); - if (secretsWithNewName.length) { - const secrets = await secretDAL.find({ - folderId, - $complex: { - operator: "and", - value: [ + // check again to avoid non authorized tags are removed + secretsToUpdate.forEach((el) => { + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Edit, + subject(ProjectPermissionSub.Secrets, { + environment, + secretPath, + secretName: el.secretKey, + secretTags: (el.tagIds || []).map((i) => tagsGroupByID[i][0].slug) + }) + ); + }); + + // now find any secret that needs to update its name + // same process as above + const secretsWithNewName = secretsToUpdate.filter(({ newSecretName }) => Boolean(newSecretName)); + if (secretsWithNewName.length) { + const secrets = await secretDAL.find( { - operator: "or", - value: secretsWithNewName.map((el) => ({ + folderId, + $complex: { operator: "and", value: [ { - operator: "eq", - field: `${TableName.SecretV2}.key` as "key", - value: el.secretKey - }, - { - operator: "eq", - field: "type", - value: SecretType.Shared + operator: "or", + value: secretsWithNewName.map((el) => ({ + operator: "and", + value: [ + { + operator: "eq", + field: `${TableName.SecretV2}.key` as "key", + value: el.secretKey + }, + { + operator: "eq", + field: "type", + value: SecretType.Shared + } + ] + })) } ] - })) - } - ] + } + }, + { tx } + ); + if (secrets.length) + throw new BadRequestError({ + message: `Secret with new name already exists: ${secretsWithNewName + .map((el) => el.newSecretName) + .join(", ")}` + }); + + secretsWithNewName.forEach((el) => { + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Create, + subject(ProjectPermissionSub.Secrets, { + environment, + secretPath, + secretName: el.newSecretName as string, + secretTags: (el.tagIds || []).map((i) => tagsGroupByID[i][0].slug) + }) + ); + }); } - }); - if (secrets.length) - throw new BadRequestError({ - message: `Secret with new name already exists: ${secretsWithNewName.map((el) => el.newSecretName).join(",")}` + // now get all secret references made and validate the permission + const secretReferencesGroupByInputSecretKey: Record> = {}; + const secretReferences: TSecretReference[] = []; + secretsToUpdate.concat(SecretUpdateMode.Upsert === updateMode ? secretsToCreate : []).forEach((el) => { + if (el.secretValue) { + const references = getAllSecretReferences(el.secretValue); + secretReferencesGroupByInputSecretKey[el.secretKey] = references; + secretReferences.push(...references.nestedReferences); + references.localReferences.forEach((localRefKey) => { + secretReferences.push({ secretKey: localRefKey, secretPath, environment }); + }); + } }); + await $validateSecretReferences(projectId, permission, secretReferences, tx); - secretsWithNewName.forEach((el) => { - ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionActions.Create, - subject(ProjectPermissionSub.Secrets, { - environment, - secretPath, - secretName: el.newSecretName as string, - secretTags: (el.tagIds || []).map((i) => tagsGroupByID[i][0].slug) - }) - ); - }); - } - // now get all secret references made and validate the permission - const secretReferencesGroupByInputSecretKey: Record> = {}; - const secretReferences: TSecretReference[] = []; - inputSecrets.forEach((el) => { - if (el.secretValue) { - const references = getAllSecretReferences(el.secretValue); - secretReferencesGroupByInputSecretKey[el.secretKey] = references; - secretReferences.push(...references.nestedReferences); - references.localReferences.forEach((localRefKey) => { - secretReferences.push({ secretKey: localRefKey, secretPath, environment }); + const bulkUpdatedSecrets = await fnSecretBulkUpdate({ + folderId, + orgId: actorOrgId, + tx, + inputSecrets: secretsToUpdate.map((el) => { + const originalSecret = secretsToUpdateInDBGroupedByKey[el.secretKey][0]; + const encryptedValue = + typeof el.secretValue !== "undefined" + ? { + encryptedValue: secretManagerEncryptor({ plainText: Buffer.from(el.secretValue) }).cipherTextBlob, + references: secretReferencesGroupByInputSecretKey[el.secretKey]?.nestedReferences + } + : {}; + + return { + filter: { id: originalSecret.id, type: SecretType.Shared }, + data: { + reminderRepeatDays: el.secretReminderRepeatDays, + encryptedComment: setKnexStringValue( + el.secretComment, + (value) => secretManagerEncryptor({ plainText: Buffer.from(value) }).cipherTextBlob + ), + reminderNote: el.secretReminderNote, + skipMultilineEncoding: el.skipMultilineEncoding, + key: el.newSecretName || el.secretKey, + tags: el.tagIds, + secretMetadata: el.secretMetadata, + ...encryptedValue + } + }; + }), + secretDAL, + secretVersionDAL, + secretTagDAL, + secretVersionTagDAL, + resourceMetadataDAL }); + updatedSecrets.push(...bulkUpdatedSecrets.map((el) => ({ ...el, secretPath: folder.path }))); + if (updateMode === SecretUpdateMode.Upsert) { + const bulkInsertedSecrets = await fnSecretBulkInsert({ + inputSecrets: secretsToCreate.map((el) => { + const references = secretReferencesGroupByInputSecretKey[el.secretKey]?.nestedReferences; + + return { + version: 1, + encryptedComment: setKnexStringValue( + el.secretComment, + (value) => secretManagerEncryptor({ plainText: Buffer.from(value) }).cipherTextBlob + ), + encryptedValue: el.secretValue + ? secretManagerEncryptor({ plainText: Buffer.from(el.secretValue) }).cipherTextBlob + : undefined, + skipMultilineEncoding: el.skipMultilineEncoding, + key: el.secretKey, + tagIds: el.tagIds, + references, + secretMetadata: el.secretMetadata, + type: SecretType.Shared + }; + }), + folderId, + orgId: actorOrgId, + secretDAL, + resourceMetadataDAL, + secretVersionDAL, + secretTagDAL, + secretVersionTagDAL, + tx + }); + updatedSecrets.push(...bulkInsertedSecrets.map((el) => ({ ...el, secretPath: folder.path }))); + } } }); - await $validateSecretReferences(projectId, permission, secretReferences); - const { encryptor: secretManagerEncryptor, decryptor: secretManagerDecryptor } = - await kmsService.createCipherPairWithDataKey({ type: KmsDataKey.SecretManager, projectId }); - - const secrets = await secretDAL.transaction(async (tx) => - fnSecretBulkUpdate({ - folderId, - orgId: actorOrgId, - tx, - inputSecrets: inputSecrets.map((el) => { - const originalSecret = secretsToUpdateInDBGroupedByKey[el.secretKey][0]; - const encryptedValue = - typeof el.secretValue !== "undefined" - ? { - encryptedValue: secretManagerEncryptor({ plainText: Buffer.from(el.secretValue) }).cipherTextBlob, - references: secretReferencesGroupByInputSecretKey[el.secretKey]?.nestedReferences - } - : {}; - - return { - filter: { id: originalSecret.id, type: SecretType.Shared }, - data: { - reminderRepeatDays: el.secretReminderRepeatDays, - encryptedComment: setKnexStringValue( - el.secretComment, - (value) => secretManagerEncryptor({ plainText: Buffer.from(value) }).cipherTextBlob - ), - reminderNote: el.secretReminderNote, - skipMultilineEncoding: el.skipMultilineEncoding, - key: el.newSecretName || el.secretKey, - tags: el.tagIds, - secretMetadata: el.secretMetadata, - ...encryptedValue - } - }; - }), - secretDAL, - secretVersionDAL, - secretTagDAL, - secretVersionTagDAL, - resourceMetadataDAL - }) + await Promise.allSettled(folders.map((el) => (el?.id ? snapshotService.performSnapshot(el.id) : undefined))); + await Promise.allSettled( + folders.map((el) => + el + ? secretQueueService.syncSecrets({ + actor, + actorId, + secretPath: el.path, + projectId, + orgId: actorOrgId, + environmentSlug: environment + }) + : undefined + ) ); - await snapshotService.performSnapshot(folderId); - await secretQueueService.syncSecrets({ - actor, - actorId, - secretPath, - projectId, - orgId: actorOrgId, - environmentSlug: folder.environment.slug - }); - return secrets.map((el) => - reshapeBridgeSecret(projectId, environment, secretPath, { + return updatedSecrets.map((el) => + reshapeBridgeSecret(projectId, environment, el.secretPath, { ...el, value: el.encryptedValue ? secretManagerDecryptor({ cipherTextBlob: el.encryptedValue }).toString() : "", comment: el.encryptedComment ? secretManagerDecryptor({ cipherTextBlob: el.encryptedComment }).toString() : "" diff --git a/backend/src/services/secret-v2-bridge/secret-v2-bridge-types.ts b/backend/src/services/secret-v2-bridge/secret-v2-bridge-types.ts index 37512d93ad..ad8264e810 100644 --- a/backend/src/services/secret-v2-bridge/secret-v2-bridge-types.ts +++ b/backend/src/services/secret-v2-bridge/secret-v2-bridge-types.ts @@ -23,6 +23,12 @@ export type TSecretReferenceDTO = { secretKey: string; }; +export enum SecretUpdateMode { + Ignore = "ignore", + Upsert = "upsert", + FailOnNotFound = "failOnNotFound" +} + export type TGetSecretsDTO = { expandSecretReferences?: boolean; path: string; @@ -113,6 +119,7 @@ export type TUpdateManySecretDTO = Omit & { secretPath: string; projectId: string; environment: string; + mode: SecretUpdateMode; secrets: { secretKey: string; newSecretName?: string; @@ -123,6 +130,7 @@ export type TUpdateManySecretDTO = Omit & { secretReminderRepeatDays?: number | null; secretReminderNote?: string | null; secretMetadata?: ResourceMetadataDTO; + secretPath?: string; }[]; }; diff --git a/backend/src/services/secret/secret-service.ts b/backend/src/services/secret/secret-service.ts index e58615b0f9..93f68e813d 100644 --- a/backend/src/services/secret/secret-service.ts +++ b/backend/src/services/secret/secret-service.ts @@ -30,7 +30,10 @@ import { groupBy, pick } from "@app/lib/fn"; import { logger } from "@app/lib/logger"; import { alphaNumericNanoId } from "@app/lib/nanoid"; import { OrgServiceActor } from "@app/lib/types"; -import { TGetSecretsRawByFolderMappingsDTO } from "@app/services/secret-v2-bridge/secret-v2-bridge-types"; +import { + SecretUpdateMode, + TGetSecretsRawByFolderMappingsDTO +} from "@app/services/secret-v2-bridge/secret-v2-bridge-types"; import { ActorType } from "../auth/auth-type"; import { TProjectDALFactory } from "../project/project-dal"; @@ -2012,6 +2015,7 @@ export const secretServiceFactory = ({ actorOrgId, actorAuthMethod, secretPath, + mode = SecretUpdateMode.FailOnNotFound, secrets: inputSecrets = [] }: TUpdateManySecretRawDTO) => { if (!projectSlug && !optionalProjectId) @@ -2076,7 +2080,8 @@ export const secretServiceFactory = ({ actorOrgId, actor, actorId, - secrets: inputSecrets + secrets: inputSecrets, + mode }); return { type: SecretProtectionType.Direct as const, secrets }; } diff --git a/backend/src/services/secret/secret-types.ts b/backend/src/services/secret/secret-types.ts index a0082efc67..1586052763 100644 --- a/backend/src/services/secret/secret-types.ts +++ b/backend/src/services/secret/secret-types.ts @@ -17,6 +17,7 @@ import { TKmsServiceFactory } from "../kms/kms-service"; import { TResourceMetadataDALFactory } from "../resource-metadata/resource-metadata-dal"; import { ResourceMetadataDTO } from "../resource-metadata/resource-metadata-schema"; import { TSecretV2BridgeDALFactory } from "../secret-v2-bridge/secret-v2-bridge-dal"; +import { SecretUpdateMode } from "../secret-v2-bridge/secret-v2-bridge-types"; import { TSecretVersionV2DALFactory } from "../secret-v2-bridge/secret-version-dal"; import { TSecretVersionV2TagDALFactory } from "../secret-v2-bridge/secret-version-tag-dal"; @@ -274,6 +275,7 @@ export type TUpdateManySecretRawDTO = Omit & { projectId?: string; projectSlug?: string; environment: string; + mode: SecretUpdateMode; secrets: { secretKey: string; newSecretName?: string;