From b563c4030baef8ae47295ca2062944610622e62c Mon Sep 17 00:00:00 2001 From: = Date: Thu, 18 Jul 2024 00:14:41 +0530 Subject: [PATCH] feat: created base for secret v2 bridge and plugged it to secret-router --- backend/src/server/routes/index.ts | 27 +- backend/src/server/routes/sanitizedSchemas.ts | 2 +- .../services/project-bot/project-bot-fns.ts | 7 +- .../secret-import/secret-import-fns.ts | 123 +- .../src/services/secret-tag/secret-tag-dal.ts | 3 + .../secret-v2-bridge/secret-v2-bridge-dal.ts | 393 +++++ .../secret-v2-bridge/secret-v2-bridge-fns.ts | 553 +++++++ .../secret-v2-bridge-service.ts | 1319 +++++++++++++++++ .../secret-v2-bridge-types.ts | 267 ++++ .../secret-v2-bridge/secret-version-dal.ts | 124 ++ .../secret-version-tag-dal.ts | 10 + backend/src/services/secret/secret-dal.ts | 2 - backend/src/services/secret/secret-fns.ts | 4 +- backend/src/services/secret/secret-service.ts | 194 ++- 14 files changed, 3001 insertions(+), 27 deletions(-) create mode 100644 backend/src/services/secret-v2-bridge/secret-v2-bridge-dal.ts create mode 100644 backend/src/services/secret-v2-bridge/secret-v2-bridge-fns.ts create mode 100644 backend/src/services/secret-v2-bridge/secret-v2-bridge-service.ts create mode 100644 backend/src/services/secret-v2-bridge/secret-v2-bridge-types.ts create mode 100644 backend/src/services/secret-v2-bridge/secret-version-dal.ts create mode 100644 backend/src/services/secret-v2-bridge/secret-version-tag-dal.ts diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index c0f3f76e2..625790da3 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -182,6 +182,10 @@ import { registerSecretScannerGhApp } from "../plugins/secret-scanner"; import { registerV1Routes } from "./v1"; import { registerV2Routes } from "./v2"; import { registerV3Routes } from "./v3"; +import { secretV2BridgeDALFactory } from "@app/services/secret-v2-bridge/secret-v2-bridge-dal"; +import { secretVersionV2BridgeDALFactory } from "@app/services/secret-v2-bridge/secret-version-dal"; +import { secretVersionV2TagBridgeDALFactory } from "@app/services/secret-v2-bridge/secret-version-tag-dal"; +import { secretV2BridgeServiceFactory } from "@app/services/secret-v2-bridge/secret-v2-bridge-service"; export const registerRoutes = async ( server: FastifyZodProvider, @@ -229,6 +233,10 @@ export const registerRoutes = async ( const secretVersionTagDAL = secretVersionTagDALFactory(db); const secretBlindIndexDAL = secretBlindIndexDALFactory(db); + const secretV2BridgeDAL = secretV2BridgeDALFactory(db); + const secretVersionV2BridgeDAL = secretVersionV2BridgeDALFactory(db); + const secretVersionV2TagBridgeDAL = secretVersionV2TagBridgeDALFactory(db); + const integrationDAL = integrationDALFactory(db); const integrationAuthDAL = integrationAuthDALFactory(db); const webhookDAL = webhookDALFactory(db); @@ -719,6 +727,22 @@ export const registerRoutes = async ( secretDAL, secretBlindIndexDAL }); + + const secretV2BridgeService = secretV2BridgeServiceFactory({ + folderDAL, + secretVersionDAL: secretVersionV2BridgeDAL, + secretQueueService, + secretDAL: secretV2BridgeDAL, + permissionService, + secretVersionTagDAL: secretVersionV2TagBridgeDAL, + secretTagDAL, + projectEnvDAL, + secretImportDAL, + secretApprovalRequestDAL, + secretApprovalPolicyService, + secretApprovalRequestSecretDAL + }); + const secretService = secretServiceFactory({ folderDAL, secretVersionDAL, @@ -735,7 +759,8 @@ export const registerRoutes = async ( projectBotService, secretApprovalPolicyService, secretApprovalRequestDAL, - secretApprovalRequestSecretDAL + secretApprovalRequestSecretDAL, + secretV2BridgeService }); const secretSharingService = secretSharingServiceFactory({ diff --git a/backend/src/server/routes/sanitizedSchemas.ts b/backend/src/server/routes/sanitizedSchemas.ts index f84cea4b8..a5751305f 100644 --- a/backend/src/server/routes/sanitizedSchemas.ts +++ b/backend/src/server/routes/sanitizedSchemas.ts @@ -62,7 +62,7 @@ export const secretRawSchema = z.object({ version: z.number(), type: z.string(), secretKey: z.string(), - secretValue: z.string(), + secretValue: z.string().optional(), secretComment: z.string().optional(), secretReminderNote: z.string().nullable().optional(), secretReminderRepeatDays: z.number().nullable().optional(), diff --git a/backend/src/services/project-bot/project-bot-fns.ts b/backend/src/services/project-bot/project-bot-fns.ts index 0f5d229d7..9269c2936 100644 --- a/backend/src/services/project-bot/project-bot-fns.ts +++ b/backend/src/services/project-bot/project-bot-fns.ts @@ -22,6 +22,10 @@ export const getBotKeyFnFactory = ( const project = await projectDAL.findById(projectId); if (!project) throw new BadRequestError({ message: "Project not found during bot lookup." }); + if (project.version === 3) { + return { project, shouldUseSecretV2Bridge: true }; + } + const bot = await projectBotDAL.findOne({ projectId: project.id }); if (!bot) throw new BadRequestError({ message: "Failed to find bot key", name: "bot_not_found_error" }); @@ -31,12 +35,13 @@ export const getBotKeyFnFactory = ( const botPrivateKey = getBotPrivateKey({ bot }); - return decryptAsymmetric({ + const botKey = decryptAsymmetric({ ciphertext: bot.encryptedProjectKey, privateKey: botPrivateKey, nonce: bot.encryptedProjectKeyNonce, publicKey: bot.sender.publicKey }); + return { botKey, project, shouldUseSecretV2Bridge: false }; }; return getBotKeyFn; diff --git a/backend/src/services/secret-import/secret-import-fns.ts b/backend/src/services/secret-import/secret-import-fns.ts index f1a3a3464..147347e25 100644 --- a/backend/src/services/secret-import/secret-import-fns.ts +++ b/backend/src/services/secret-import/secret-import-fns.ts @@ -1,8 +1,9 @@ -import { SecretType, TSecretImports, TSecrets } from "@app/db/schemas"; +import { SecretType, TSecretImports, TSecrets, TSecretsV2 } from "@app/db/schemas"; import { groupBy } from "@app/lib/fn"; import { TSecretDALFactory } from "../secret/secret-dal"; import { TSecretFolderDALFactory } from "../secret-folder/secret-folder-dal"; +import { TSecretV2BridgeDALFactory } from "../secret-v2-bridge/secret-v2-bridge-dal"; import { TSecretImportDALFactory } from "./secret-import-dal"; type TSecretImportSecrets = { @@ -18,6 +19,28 @@ type TSecretImportSecrets = { secrets: (TSecrets & { workspace: string; environment: string; _id: string })[]; }; +type TSecretImportSecretsV2 = { + secretPath: string; + environment: string; + environmentInfo: { + id: string; + slug: string; + name: string; + }; + folderId: string | undefined; + importFolderId: string; + secrets: (TSecretsV2 & { + workspace: string; + environment: string; + _id: string; + secretKey: string; + // akhilmhdh: yes i know you can put ?. + // But for somereason ts consider ? and undefined explicit as different just ts things + secretValue: string | undefined; + secretComment: string | undefined; + })[]; +}; + const LEVEL_BREAK = 10; const getImportUniqKey = (envSlug: string, path: string) => `${envSlug}=${path}`; export const fnSecretsFromImports = async ({ @@ -115,3 +138,101 @@ export const fnSecretsFromImports = async ({ return secrets; }; + +export const fnSecretsV2FromImports = async ({ + allowedImports: possibleCyclicImports, + folderDAL, + secretDAL, + secretImportDAL, + depth = 0, + cyclicDetector = new Set() +}: { + allowedImports: (Omit & { + importEnv: { id: string; slug: string; name: string }; + })[]; + folderDAL: Pick; + secretDAL: Pick; + secretImportDAL: Pick; + depth?: number; + cyclicDetector?: Set; +}) => { + // avoid going more than a depth + if (depth >= LEVEL_BREAK) return []; + + const allowedImports = possibleCyclicImports.filter( + ({ importPath, importEnv }) => !cyclicDetector.has(getImportUniqKey(importEnv.slug, importPath)) + ); + + const importedFolders = ( + await folderDAL.findByManySecretPath( + allowedImports.map(({ importEnv, importPath }) => ({ + envId: importEnv.id, + secretPath: importPath + })) + ) + ).filter(Boolean); // remove undefined ones + if (!importedFolders.length) { + return []; + } + + const importedFolderIds = importedFolders.map((el) => el?.id) as string[]; + const importedFolderGroupBySourceImport = groupBy(importedFolders, (i) => `${i?.envId}-${i?.path}`); + const importedSecrets = await secretDAL.find( + { + $in: { folderId: importedFolderIds }, + type: SecretType.Shared + }, + { + sort: [["id", "asc"]] + } + ); + + const importedSecretsGroupByFolderId = groupBy(importedSecrets, (i) => i.folderId); + + allowedImports.forEach(({ importPath, importEnv }) => { + cyclicDetector.add(getImportUniqKey(importEnv.slug, importPath)); + }); + // now we need to check recursively deeper imports made inside other imports + // we go level wise meaning we take all imports of a tree level and then go deeper ones level by level + const deeperImports = await secretImportDAL.findByFolderIds(importedFolderIds); + let secretsFromDeeperImports: TSecretImportSecretsV2[] = []; + if (deeperImports.length) { + secretsFromDeeperImports = await fnSecretsV2FromImports({ + allowedImports: deeperImports.filter(({ isReplication }) => !isReplication), + secretImportDAL, + folderDAL, + secretDAL, + depth: depth + 1, + cyclicDetector + }); + } + const secretsFromdeeperImportGroupedByFolderId = groupBy(secretsFromDeeperImports, (i) => i.importFolderId); + + const secrets = allowedImports.map(({ importPath, importEnv, id, folderId }, i) => { + const sourceImportFolder = importedFolderGroupBySourceImport[`${importEnv.id}-${importPath}`][0]; + const folderDeeperImportSecrets = + secretsFromdeeperImportGroupedByFolderId?.[sourceImportFolder?.id || ""]?.[0]?.secrets || []; + + return { + secretPath: importPath, + environment: importEnv.slug, + environmentInfo: importEnv, + folderId: importedFolders?.[i]?.id, + id, + importFolderId: folderId, + secrets: (importedSecretsGroupByFolderId?.[importedFolders?.[i]?.id as string] || []) + .map((item) => ({ + ...item, + secretKey: item.key, + secretValue: item.encryptedValue?.toString(), + secretComment: item.encryptedComment?.toString(), + environment: importEnv.slug, + workspace: "", // This field should not be used, it's only here to keep the older Python SDK versions backwards compatible with the new Postgres backend. + _id: item.id // The old Python SDK depends on the _id field being returned. We return this to keep the older Python SDK versions backwards compatible with the new Postgres backend. + })) + .concat(folderDeeperImportSecrets) + }; + }); + + return secrets; +}; diff --git a/backend/src/services/secret-tag/secret-tag-dal.ts b/backend/src/services/secret-tag/secret-tag-dal.ts index 98cd9af22..b1dd1a00b 100644 --- a/backend/src/services/secret-tag/secret-tag-dal.ts +++ b/backend/src/services/secret-tag/secret-tag-dal.ts @@ -10,6 +10,7 @@ export type TSecretTagDALFactory = ReturnType; export const secretTagDALFactory = (db: TDbClient) => { const secretTagOrm = ormify(db, TableName.SecretTag); const secretJnTagOrm = ormify(db, TableName.JnSecretTag); + const secretV2JnTagOrm = ormify(db, TableName.SecretV2JnTag); const findManyTagsById = async (projectId: string, ids: string[], tx?: Knex) => { try { @@ -38,6 +39,8 @@ export const secretTagDALFactory = (db: TDbClient) => { ...secretTagOrm, saveTagsToSecret: secretJnTagOrm.insertMany, deleteTagsToSecret: secretJnTagOrm.delete, + saveTagsToSecretV2: secretV2JnTagOrm.insertMany, + deleteTagsToSecretV2: secretV2JnTagOrm.delete, deleteTagsManySecret, findManyTagsById }; diff --git a/backend/src/services/secret-v2-bridge/secret-v2-bridge-dal.ts b/backend/src/services/secret-v2-bridge/secret-v2-bridge-dal.ts new file mode 100644 index 000000000..b41e2c250 --- /dev/null +++ b/backend/src/services/secret-v2-bridge/secret-v2-bridge-dal.ts @@ -0,0 +1,393 @@ +import { Knex } from "knex"; +import { validate as uuidValidate } from "uuid"; + +import { TDbClient } from "@app/db"; +import { SecretsV2Schema, SecretType, TableName, TSecretsV2, TSecretsV2Update } from "@app/db/schemas"; +import { BadRequestError, DatabaseError } from "@app/lib/errors"; +import { ormify, selectAllTableCols, sqlNestRelationships } from "@app/lib/knex"; + +export type TSecretV2BridgeDALFactory = ReturnType; + +export const secretV2BridgeDALFactory = (db: TDbClient) => { + const secretOrm = ormify(db, TableName.SecretV2); + + const update = async (filter: Partial, data: Omit, tx?: Knex) => { + try { + const sec = await (tx || db)(TableName.SecretV2) + .where(filter) + .update(data) + .increment("version", 1) + .returning("*"); + return sec; + } catch (error) { + throw new DatabaseError({ error, name: "update secret" }); + } + }; + + const bulkUpdate = async ( + data: Array<{ filter: Partial; data: TSecretsV2Update }>, + + tx?: Knex + ) => { + try { + const secs = await Promise.all( + data.map(async ({ filter, data: updateData }) => { + const [doc] = await (tx || db)(TableName.SecretV2) + .where(filter) + .update(updateData) + .increment("version", 1) + .returning("*"); + if (!doc) throw new BadRequestError({ message: "Failed to update document" }); + return doc; + }) + ); + return secs; + } catch (error) { + throw new DatabaseError({ error, name: "bulk update secret" }); + } + }; + + const bulkUpdateNoVersionIncrement = async (data: TSecretsV2[], tx?: Knex) => { + try { + const existingSecrets = await secretOrm.find( + { + $in: { + id: data.map((el) => el.id) + } + }, + { tx } + ); + + if (existingSecrets.length !== data.length) { + throw new BadRequestError({ message: "Some of the secrets do not exist" }); + } + + if (data.length === 0) return []; + + const updatedSecrets = await (tx || db)(TableName.SecretV2) + .insert(data) + .onConflict("id") // this will cause a conflict then merge the data + .merge() // Merge the data with the existing data + .returning("*"); + + return updatedSecrets; + } catch (error) { + throw new DatabaseError({ error, name: "bulk update secret" }); + } + }; + + const deleteMany = async ( + data: Array<{ key: string; type: SecretType }>, + folderId: string, + userId: string, + tx?: Knex + ) => { + try { + const deletedSecrets = await (tx || db)(TableName.SecretV2) + .where({ folderId }) + .where((bd) => { + data.forEach((el) => { + void bd.orWhere({ + key: el.key, + type: el.type, + ...(el.type === SecretType.Personal ? { userId } : {}) + }); + // if shared is getting deleted then personal ones also should be deleted + if (el.type === SecretType.Shared) { + void bd.orWhere({ + key: el.key, + type: SecretType.Personal + }); + } + }); + }) + .delete() + .returning("*"); + return deletedSecrets; + } catch (error) { + throw new DatabaseError({ error, name: "delete many secret" }); + } + }; + + const findByFolderId = async (folderId: string, userId?: string, tx?: Knex) => { + try { + // check if not uui then userId id is null (corner case because service token's ID is not UUI in effort to keep backwards compatibility from mongo) + if (userId && !uuidValidate(userId)) { + // eslint-disable-next-line + userId = undefined; + } + + const secs = await (tx || db.replicaNode())(TableName.SecretV2) + .where({ folderId }) + .where((bd) => { + void bd.whereNull("userId").orWhere({ userId: userId || null }); + }) + .leftJoin( + TableName.SecretV2JnTag, + `${TableName.SecretV2}.id`, + `${TableName.SecretV2JnTag}.${TableName.SecretV2}Id` + ) + .leftJoin( + TableName.SecretTag, + `${TableName.SecretV2JnTag}.${TableName.SecretTag}Id`, + `${TableName.SecretTag}.id` + ) + .select(selectAllTableCols(TableName.SecretV2)) + .select(db.ref("id").withSchema(TableName.SecretTag).as("tagId")) + .select(db.ref("color").withSchema(TableName.SecretTag).as("tagColor")) + .select(db.ref("slug").withSchema(TableName.SecretTag).as("tagSlug")) + .select(db.ref("name").withSchema(TableName.SecretTag).as("tagName")) + .orderBy("id", "asc"); + + const data = sqlNestRelationships({ + data: secs, + key: "id", + parentMapper: (el) => ({ _id: el.id, ...SecretsV2Schema.parse(el) }), + childrenMapper: [ + { + key: "tagId", + label: "tags" as const, + mapper: ({ tagId: id, tagColor: color, tagSlug: slug, tagName: name }) => ({ + id, + color, + slug, + name + }) + } + ] + }); + return data; + } catch (error) { + throw new DatabaseError({ error, name: "get all secret" }); + } + }; + + const getSecretTags = async (secretId: string, tx?: Knex) => { + try { + const tags = await (tx || db.replicaNode())(TableName.SecretV2JnTag) + .join(TableName.SecretTag, `${TableName.SecretV2JnTag}.${TableName.SecretTag}Id`, `${TableName.SecretTag}.id`) + .where({ [`${TableName.SecretV2}Id` as const]: secretId }) + .select(db.ref("id").withSchema(TableName.SecretTag).as("tagId")) + .select(db.ref("color").withSchema(TableName.SecretTag).as("tagColor")) + .select(db.ref("slug").withSchema(TableName.SecretTag).as("tagSlug")) + .select(db.ref("name").withSchema(TableName.SecretTag).as("tagName")); + + return tags.map((el) => ({ + id: el.tagId, + color: el.tagColor, + slug: el.tagSlug, + name: el.tagName + })); + } catch (error) { + throw new DatabaseError({ error, name: "get secret tags" }); + } + }; + + const findByFolderIds = async (folderIds: string[], userId?: string, tx?: Knex) => { + try { + // check if not uui then userId id is null (corner case because service token's ID is not UUI in effort to keep backwards compatibility from mongo) + if (userId && !uuidValidate(userId)) { + // eslint-disable-next-line no-param-reassign + userId = undefined; + } + + const secs = await (tx || db.replicaNode())(TableName.SecretV2) + .whereIn("folderId", folderIds) + .where((bd) => { + void bd.whereNull("userId").orWhere({ userId: userId || null }); + }) + .leftJoin( + TableName.SecretV2JnTag, + `${TableName.SecretV2}.id`, + `${TableName.SecretV2JnTag}.${TableName.SecretV2}Id` + ) + .leftJoin( + TableName.SecretTag, + `${TableName.SecretV2JnTag}.${TableName.SecretTag}Id`, + `${TableName.SecretTag}.id` + ) + .select(selectAllTableCols(TableName.SecretV2)) + .select(db.ref("id").withSchema(TableName.SecretTag).as("tagId")) + .select(db.ref("color").withSchema(TableName.SecretTag).as("tagColor")) + .select(db.ref("slug").withSchema(TableName.SecretTag).as("tagSlug")) + .select(db.ref("name").withSchema(TableName.SecretTag).as("tagName")) + .orderBy("id", "asc"); + + const data = sqlNestRelationships({ + data: secs, + key: "id", + parentMapper: (el) => ({ _id: el.id, ...SecretsV2Schema.parse(el) }), + childrenMapper: [ + { + key: "tagId", + label: "tags" as const, + mapper: ({ tagId: id, tagColor: color, tagSlug: slug, tagName: name }) => ({ + id, + color, + slug, + name + }) + } + ] + }); + return data; + } catch (error) { + throw new DatabaseError({ error, name: "get all secret" }); + } + }; + + const findBySecretKeys = async ( + folderId: string, + query: Array<{ key: string; type: SecretType.Shared } | { key: string; type: SecretType.Personal; userId: string }>, + tx?: Knex + ) => { + if (!query.length) return []; + try { + const secrets = await (tx || db.replicaNode())(TableName.SecretV2) + .where({ folderId }) + .where((bd) => { + query.forEach((el) => { + if (el.type === SecretType.Personal && !el.userId) { + throw new BadRequestError({ message: "Missing personal user id" }); + } + void bd.orWhere({ + key: el.key, + type: el.type, + userId: el.type === SecretType.Personal ? el.userId : null + }); + }); + }); + return secrets; + } catch (error) { + throw new DatabaseError({ error, name: "find by blind indexes" }); + } + }; + + const upsertSecretReferences = async ( + data: { + secretId: string; + references: Array<{ environment: string; secretPath: string; secretKey: string }>; + }[] = [], + tx?: Knex + ) => { + try { + if (!data.length) return; + + await (tx || db)(TableName.SecretReferenceV2) + .whereIn( + "secretId", + data.map(({ secretId }) => secretId) + ) + .delete(); + const newSecretReferences = data + .filter(({ references }) => references.length) + .flatMap(({ secretId, references }) => + references.map(({ environment, secretPath, secretKey }) => ({ + secretPath, + secretId, + environment, + secretKey + })) + ); + if (!newSecretReferences.length) return; + const secretReferences = await (tx || db)(TableName.SecretReferenceV2).insert(newSecretReferences); + return secretReferences; + } catch (error) { + throw new DatabaseError({ error, name: "UpsertSecretReference" }); + } + }; + + const findReferencedSecretReferences = async (projectId: string, envSlug: string, secretPath: string, tx?: Knex) => { + try { + const docs = await (tx || db.replicaNode())(TableName.SecretReferenceV2) + .where({ + secretPath, + environment: envSlug + }) + .join(TableName.SecretV2, `${TableName.SecretV2}.id`, `${TableName.SecretReferenceV2}.secretId`) + .join(TableName.SecretFolder, `${TableName.SecretV2}.folderId`, `${TableName.SecretFolder}.id`) + .join(TableName.Environment, `${TableName.SecretFolder}.envId`, `${TableName.Environment}.id`) + .where("projectId", projectId) + .select(selectAllTableCols(TableName.SecretReferenceV2)) + .select("folderId"); + + return docs; + } catch (error) { + throw new DatabaseError({ error, name: "FindReferencedSecretReferences" }); + } + }; + + // special query to backfill secret value + const findAllProjectSecretValues = async (projectId: string, tx?: Knex) => { + try { + const docs = await (tx || db.replicaNode())(TableName.SecretV2) + .join(TableName.SecretFolder, `${TableName.SecretV2}.folderId`, `${TableName.SecretFolder}.id`) + .join(TableName.Environment, `${TableName.SecretFolder}.envId`, `${TableName.Environment}.id`) + .where("projectId", projectId) + // not empty + .whereNotNull("encryptedValue") + .select("encryptedValue", `${TableName.SecretV2}.id` as "id"); + return docs; + } catch (error) { + throw new DatabaseError({ error, name: "FindAllProjectSecretValues" }); + } + }; + + const findOneWithTags = async (filter: Partial, tx?: Knex) => { + try { + const rawDocs = await (tx || db.replicaNode())(TableName.SecretV2) + .where(filter) + .leftJoin( + TableName.SecretV2JnTag, + `${TableName.SecretV2}.id`, + `${TableName.SecretV2JnTag}.${TableName.SecretV2}Id` + ) + .leftJoin( + TableName.SecretTag, + `${TableName.SecretV2JnTag}.${TableName.SecretTag}Id`, + `${TableName.SecretTag}.id` + ) + .select(selectAllTableCols(TableName.SecretV2)) + .select(db.ref("id").withSchema(TableName.SecretTag).as("tagId")) + .select(db.ref("color").withSchema(TableName.SecretTag).as("tagColor")) + .select(db.ref("slug").withSchema(TableName.SecretTag).as("tagSlug")) + .select(db.ref("name").withSchema(TableName.SecretTag).as("tagName")); + const docs = sqlNestRelationships({ + data: rawDocs, + key: "id", + parentMapper: (el) => ({ _id: el.id, ...SecretsV2Schema.parse(el) }), + childrenMapper: [ + { + key: "tagId", + label: "tags" as const, + mapper: ({ tagId: id, tagColor: color, tagSlug: slug, tagName: name }) => ({ + id, + color, + slug, + name + }) + } + ] + }); + return docs?.[0]; + } catch (error) { + throw new DatabaseError({ error, name: "FindOneWIthTags" }); + } + }; + + return { + ...secretOrm, + update, + bulkUpdate, + deleteMany, + bulkUpdateNoVersionIncrement, + getSecretTags, + findOneWithTags, + findByFolderId, + findByFolderIds, + findBySecretKeys, + upsertSecretReferences, + findReferencedSecretReferences, + findAllProjectSecretValues + }; +}; diff --git a/backend/src/services/secret-v2-bridge/secret-v2-bridge-fns.ts b/backend/src/services/secret-v2-bridge/secret-v2-bridge-fns.ts new file mode 100644 index 000000000..200791382 --- /dev/null +++ b/backend/src/services/secret-v2-bridge/secret-v2-bridge-fns.ts @@ -0,0 +1,553 @@ +import path from "node:path"; + +import { TableName, TSecretFolders, TSecretsV2 } from "@app/db/schemas"; +import { groupBy } from "@app/lib/fn"; +import { logger } from "@app/lib/logger"; + +import { TProjectEnvDALFactory } from "../project-env/project-env-dal"; +import { TSecretFolderDALFactory } from "../secret-folder/secret-folder-dal"; +import { TSecretV2BridgeDALFactory } from "./secret-v2-bridge-dal"; +import { TFnSecretBulkDelete, TFnSecretBulkInsert, TFnSecretBulkUpdate } from "./secret-v2-bridge-types"; + +const INTERPOLATION_SYNTAX_REG = /\${([^}]+)}/g; + +export const shouldUseSecretV2Bridge = (version: number) => version === 3; + +/** + * Grabs and processes nested secret references from a string + * + * This function looks for patterns that match the interpolation syntax in the input string. + * It filters out references that include nested paths, splits them into environment and + * secret path parts, and then returns an array of objects with the environment and the + * joined secret path. + * @example + * const value = "Hello ${dev.someFolder.OtherFolder.SECRET_NAME} and ${prod.anotherFolder.SECRET_NAME}"; + * const result = getAllNestedSecretReferences(value); + * // result will be: + * // [ + * // { environment: 'dev', secretPath: '/someFolder/OtherFolder' }, + * // { environment: 'prod', secretPath: '/anotherFolder' } + * // ] + */ +export const getAllNestedSecretReferences = (maybeSecretReference: string) => { + const references = Array.from(maybeSecretReference.matchAll(INTERPOLATION_SYNTAX_REG), (m) => m[1]); + return references + .filter((el) => el.includes(".")) + .map((el) => { + const [environment, ...secretPathList] = el.split("."); + return { + environment, + secretPath: path.join("/", ...secretPathList.slice(0, -1)), + secretKey: secretPathList[secretPathList.length - 1] + }; + }); +}; + +// these functions are special functions shared by a couple of resources +// used by secret approval, rotation or anywhere in which secret needs to modified +export const fnSecretBulkInsert = async ({ + // TODO: Pick types here + folderId, + inputSecrets, + secretDAL, + secretVersionDAL, + secretTagDAL, + secretVersionTagDAL, + tx +}: TFnSecretBulkInsert) => { + const sanitizedInputSecrets = inputSecrets.map( + ({ + skipMultilineEncoding, + type, + key, + userId, + encryptedComment, + version, + metadata, + reminderNote, + encryptedValue, + reminderRepeatDays + }) => ({ + skipMultilineEncoding, + type, + key, + userId, + encryptedComment, + version, + metadata, + reminderNote, + encryptedValue, + reminderRepeatDays + }) + ); + + const newSecrets = await secretDAL.insertMany(sanitizedInputSecrets.map((el) => ({ ...el, folderId }))); + const newSecretGroupedByKeyName = groupBy(newSecrets, (item) => item.key); + const newSecretTags = inputSecrets.flatMap(({ tagIds: secretTags = [], key }) => + secretTags.map((tag) => ({ + [`${TableName.SecretTag}Id` as const]: tag, + [`${TableName.SecretV2}Id` as const]: newSecretGroupedByKeyName[key][0].id + })) + ); + const secretVersions = await secretVersionDAL.insertMany( + sanitizedInputSecrets.map((el) => ({ + ...el, + folderId, + secretId: newSecretGroupedByKeyName[el.key][0].id + })), + tx + ); + await secretDAL.upsertSecretReferences( + inputSecrets.map(({ references = [], key }) => ({ + secretId: newSecretGroupedByKeyName[key][0].id, + references + })), + tx + ); + if (newSecretTags.length) { + const secTags = await secretTagDAL.saveTagsToSecretV2(newSecretTags, tx); + const secVersionsGroupBySecId = groupBy(secretVersions, (i) => i.secretId); + const newSecretVersionTags = secTags.flatMap(({ secrets_v2Id, secret_tagsId }) => ({ + [`${TableName.SecretVersionV2}Id` as const]: secVersionsGroupBySecId[secrets_v2Id][0].id, + [`${TableName.SecretTag}Id` as const]: secret_tagsId + })); + await secretVersionTagDAL.insertMany(newSecretVersionTags, tx); + } + + return newSecrets.map((secret) => ({ ...secret, _id: secret.id })); +}; + +export const fnSecretBulkUpdate = async ({ + tx, + inputSecrets, + folderId, + secretDAL, + secretVersionDAL, + secretTagDAL, + secretVersionTagDAL +}: TFnSecretBulkUpdate) => { + const sanitizedInputSecrets = inputSecrets.map( + ({ + filter, + data: { + skipMultilineEncoding, + type, + key, + encryptedValue, + userId, + encryptedComment, + version, + metadata, + reminderNote, + reminderRepeatDays + } + }) => ({ + filter: { ...filter, folderId }, + data: { + skipMultilineEncoding, + type, + key, + userId, + encryptedComment, + version, + metadata, + reminderNote, + encryptedValue, + reminderRepeatDays + } + }) + ); + + const newSecrets = await secretDAL.bulkUpdate(sanitizedInputSecrets, tx); + const secretVersions = await secretVersionDAL.insertMany( + newSecrets.map( + ({ + skipMultilineEncoding, + type, + key, + userId, + encryptedComment, + version, + metadata, + reminderNote, + encryptedValue, + reminderRepeatDays, + id: secretId + }) => ({ + skipMultilineEncoding, + type, + key, + userId, + encryptedComment, + version, + metadata, + reminderNote, + encryptedValue, + reminderRepeatDays, + folderId, + secretId + }) + ), + tx + ); + await secretDAL.upsertSecretReferences( + inputSecrets + .filter(({ data: { references } }) => Boolean(references)) + .map(({ data: { references = [] } }, i) => ({ + secretId: newSecrets[i].id, + references + })), + tx + ); + const secsUpdatedTag = inputSecrets.flatMap(({ data: { tags } }, i) => + tags !== undefined ? { tags, secretId: newSecrets[i].id } : [] + ); + if (secsUpdatedTag.length) { + await secretTagDAL.deleteTagsToSecretV2({ $in: { id: secsUpdatedTag.map(({ secretId }) => secretId) } }, tx); + const newSecretTags = secsUpdatedTag.flatMap(({ tags: secretTags = [], secretId }) => + secretTags.map((tag) => ({ + [`${TableName.SecretTag}Id` as const]: tag, + [`${TableName.SecretV2}Id` as const]: secretId + })) + ); + if (newSecretTags.length) { + const secTags = await secretTagDAL.saveTagsToSecretV2(newSecretTags, tx); + const secVersionsGroupBySecId = groupBy(secretVersions, (i) => i.secretId); + const newSecretVersionTags = secTags.flatMap(({ secrets_v2Id, secret_tagsId }) => ({ + [`${TableName.SecretVersionV2}Id` as const]: secVersionsGroupBySecId[secrets_v2Id][0].id, + [`${TableName.SecretTag}Id` as const]: secret_tagsId + })); + await secretVersionTagDAL.insertMany(newSecretVersionTags, tx); + } + } + + return newSecrets.map((secret) => ({ ...secret, _id: secret.id })); +}; + +export const fnSecretBulkDelete = async ({ + folderId, + inputSecrets, + tx, + actorId, + secretDAL, + secretQueueService +}: TFnSecretBulkDelete) => { + const deletedSecrets = await secretDAL.deleteMany( + inputSecrets.map(({ type, secretKey }) => ({ + key: secretKey, + type + })), + folderId, + actorId, + tx + ); + + await Promise.allSettled( + deletedSecrets + .filter(({ reminderRepeatDays }) => Boolean(reminderRepeatDays)) + .map(({ id, reminderRepeatDays }) => + secretQueueService.removeSecretReminder({ secretId: id, repeatDays: reminderRepeatDays as number }) + ) + ); + + return deletedSecrets; +}; + +// Introduce a new interface for mapping parent IDs to their children +interface FolderMap { + [parentId: string]: TSecretFolders[]; +} +const buildHierarchy = (folders: TSecretFolders[]): FolderMap => { + const map: FolderMap = {}; + map.null = []; // Initialize mapping for root directory + + folders.forEach((folder) => { + const parentId = folder.parentId || "null"; + if (!map[parentId]) { + map[parentId] = []; + } + map[parentId].push(folder); + }); + + return map; +}; + +const generatePaths = ( + map: FolderMap, + parentId: string = "null", + basePath: string = "", + currentDepth: number = 0 +): { path: string; folderId: string }[] => { + const children = map[parentId || "null"] || []; + let paths: { path: string; folderId: string }[] = []; + + children.forEach((child) => { + // Determine if this is the root folder of the environment. If no parentId is present and the name is root, it's the root folder + const isRootFolder = child.name === "root" && !child.parentId; + + // Form the current path based on the base path and the current child + // eslint-disable-next-line no-nested-ternary + const currPath = basePath === "" ? (isRootFolder ? "/" : `/${child.name}`) : `${basePath}/${child.name}`; + + // Add the current path + paths.push({ + path: currPath, + folderId: child.id + }); + + // We make sure that the recursion depth doesn't exceed 20. + // We do this to create "circuit break", basically to ensure that we can't encounter any potential memory leaks. + if (currentDepth >= 20) { + logger.info(`generatePaths: Recursion depth exceeded 20, breaking out of recursion [map=${JSON.stringify(map)}]`); + return; + } + // Recursively generate paths for children, passing down the formatted path + const childPaths = generatePaths(map, child.id, currPath, currentDepth + 1); + paths = paths.concat( + childPaths.map((p) => ({ + path: p.path, + folderId: p.folderId + })) + ); + }); + + return paths; +}; + +type TRecursivelyFetchSecretsFromFoldersArg = { + folderDAL: Pick; + projectEnvDAL: Pick; + projectId: string; + environment: string; + currentPath: string; + hasAccess: (environment: string, secretPath: string) => boolean; +}; + +export const recursivelyGetSecretPaths = async ({ + folderDAL, + projectEnvDAL, + projectId, + environment, + currentPath, + hasAccess +}: TRecursivelyFetchSecretsFromFoldersArg) => { + const env = await projectEnvDAL.findOne({ + projectId, + slug: environment + }); + + if (!env) { + throw new Error(`'${environment}' environment not found in project with ID ${projectId}`); + } + + // Fetch all folders in env once with a single query + const folders = await folderDAL.find({ + envId: env.id, + isReserved: false + }); + + // Build the folder hierarchy map + const folderMap = buildHierarchy(folders); + + // Generate the paths paths and normalize the root path to / + const paths = generatePaths(folderMap).map((p) => ({ + path: p.path === "/" ? p.path : p.path.substring(1), + folderId: p.folderId + })); + + // Filter out paths that the user does not have permission to access, and paths that are not in the current path + const allowedPaths = paths.filter( + (folder) => hasAccess(environment, folder.path) && folder.path.startsWith(currentPath === "/" ? "" : currentPath) + ); + + return allowedPaths; +}; + +type TInterpolateSecretArg = { + projectId: string; + decryptSecret: (encryptedValue?: Buffer | null) => string; + secretDAL: Pick; + folderDAL: Pick; +}; + +export const interpolateSecrets = ({ projectId, decryptSecret, secretDAL, folderDAL }: TInterpolateSecretArg) => { + const fetchSecretsCrossEnv = () => { + const fetchCache: Record> = {}; + + return async (secRefEnv: string, secRefPath: string[], secRefKey: string) => { + const secRefPathUrl = path.join("/", ...secRefPath); + const uniqKey = `${secRefEnv}-${secRefPathUrl}`; + + if (fetchCache?.[uniqKey]) { + return fetchCache[uniqKey][secRefKey]; + } + + const folder = await folderDAL.findBySecretPath(projectId, secRefEnv, secRefPathUrl); + if (!folder) return ""; + const secrets = await secretDAL.findByFolderId(folder.id); + + const decryptedSec = secrets.reduce>((prev, secret) => { + // eslint-disable-next-line + prev[secret.key] = decryptSecret(secret.encryptedValue); + return prev; + }, {}); + + fetchCache[uniqKey] = decryptedSec; + + return fetchCache[uniqKey][secRefKey]; + }; + }; + + const recursivelyExpandSecret = async ( + expandedSec: Record, + interpolatedSec: Record, + fetchCrossEnv: (env: string, secPath: string[], secKey: string) => Promise, + recursionChainBreaker: Record, + key: string + ) => { + if (expandedSec?.[key] !== undefined) { + return expandedSec[key]; + } + if (recursionChainBreaker?.[key]) { + return ""; + } + // eslint-disable-next-line + recursionChainBreaker[key] = true; + + let interpolatedValue = interpolatedSec[key]; + if (!interpolatedValue) { + // eslint-disable-next-line no-console + console.error(`Couldn't find referenced value - ${key}`); + return ""; + } + + const refs = interpolatedValue.match(INTERPOLATION_SYNTAX_REG); + if (refs) { + for (const interpolationSyntax of refs) { + const interpolationKey = interpolationSyntax.slice(2, interpolationSyntax.length - 1); + const entities = interpolationKey.trim().split("."); + + if (entities.length === 1) { + // eslint-disable-next-line + const val = await recursivelyExpandSecret( + expandedSec, + interpolatedSec, + fetchCrossEnv, + recursionChainBreaker, + interpolationKey + ); + if (val) { + interpolatedValue = interpolatedValue.replaceAll(interpolationSyntax, val); + } + // eslint-disable-next-line + continue; + } + + if (entities.length > 1) { + const secRefEnv = entities[0]; + const secRefPath = entities.slice(1, entities.length - 1); + const secRefKey = entities[entities.length - 1]; + + // eslint-disable-next-line + const val = await fetchCrossEnv(secRefEnv, secRefPath, secRefKey); + if (val) { + interpolatedValue = interpolatedValue.replaceAll(interpolationSyntax, val); + } + } + } + } + + // eslint-disable-next-line + expandedSec[key] = interpolatedValue; + return interpolatedValue; + }; + + // used to convert multi line ones to quotes ones with \n + const formatMultiValueEnv = (val?: string) => { + if (!val) return ""; + if (!val.match("\n")) return val; + return `"${val.replace(/\n/g, "\\n")}"`; + }; + + const expandSecrets = async ( + secrets: Record + ) => { + const expandedSec: Record = {}; + const interpolatedSec: Record = {}; + + const crossSecEnvFetch = fetchSecretsCrossEnv(); + + Object.keys(secrets).forEach((key) => { + if (secrets[key].value.match(INTERPOLATION_SYNTAX_REG)) { + interpolatedSec[key] = secrets[key].value; + } else { + expandedSec[key] = secrets[key].value; + } + }); + + for (const key of Object.keys(secrets)) { + if (expandedSec?.[key]) { + // should not do multi line encoding if user has set it to skip + // eslint-disable-next-line + secrets[key].value = secrets[key].skipMultilineEncoding + ? formatMultiValueEnv(expandedSec[key]) + : expandedSec[key]; + // eslint-disable-next-line + continue; + } + + // this is to avoid recursion loop. So the graph should be direct graph rather than cyclic + // so for any recursion building if there is an entity two times same key meaning it will be looped + const recursionChainBreaker: Record = {}; + // eslint-disable-next-line + const expandedVal = await recursivelyExpandSecret( + expandedSec, + interpolatedSec, + crossSecEnvFetch, + recursionChainBreaker, + key + ); + + // eslint-disable-next-line + secrets[key].value = secrets[key].skipMultilineEncoding ? formatMultiValueEnv(expandedVal) : expandedVal; + } + + return secrets; + }; + return expandSecrets; +}; + +export const reshapeBridgeSecret = ( + workspaceId: string, + environment: string, + secretPath: string, + secret: Omit & { + value?: string; + comment?: string; + tags?: { + id: string; + slug: string; + color?: string | null; + name: string; + }[]; + } +) => ({ + secretKey: secret.key, + secretPath, + workspace: workspaceId, + environment, + secretValue: secret.value, + secretComment: secret.comment, + version: secret.version, + type: secret.type, + _id: secret.id, + id: secret.id, + user: secret.userId, + tags: secret.tags, + skipMultilineEncoding: secret.skipMultilineEncoding, + secretReminderRepeatDays: secret.reminderRepeatDays, + secretReminderNote: secret.reminderNote, + metadata: secret.metadata, + createdAt: secret.createdAt, + updatedAt: secret.updatedAt +}); 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 new file mode 100644 index 000000000..5d94d4930 --- /dev/null +++ b/backend/src/services/secret-v2-bridge/secret-v2-bridge-service.ts @@ -0,0 +1,1319 @@ +import { ForbiddenError, subject } from "@casl/ability"; + +import { ProjectMembershipRole, SecretsV2Schema, SecretType } 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 { BadRequestError, NotFoundError } from "@app/lib/errors"; +import { groupBy } from "@app/lib/fn"; +import { logger } from "@app/lib/logger"; +import { alphaNumericNanoId } from "@app/lib/nanoid"; + +import { ActorType } from "../auth/auth-type"; +import { TProjectEnvDALFactory } from "../project-env/project-env-dal"; +import { TSecretQueueFactory } from "../secret/secret-queue"; +import { TSecretFolderDALFactory } from "../secret-folder/secret-folder-dal"; +import { TSecretImportDALFactory } from "../secret-import/secret-import-dal"; +import { fnSecretsV2FromImports } from "../secret-import/secret-import-fns"; +import { TSecretTagDALFactory } from "../secret-tag/secret-tag-dal"; +import { TSecretV2BridgeDALFactory } from "./secret-v2-bridge-dal"; +import { + fnSecretBulkDelete, + fnSecretBulkInsert, + fnSecretBulkUpdate, + getAllNestedSecretReferences, + interpolateSecrets, + recursivelyGetSecretPaths, + reshapeBridgeSecret +} from "./secret-v2-bridge-fns"; +import { + SecretOperations, + TBackFillSecretReferencesDTO, + TCreateManySecretDTO, + TCreateSecretDTO, + TDeleteManySecretDTO, + TDeleteSecretDTO, + TGetASecretDTO, + TGetSecretsDTO, + TGetSecretVersionsDTO, + TMoveSecretsDTO, + TUpdateManySecretDTO, + TUpdateSecretDTO +} from "./secret-v2-bridge-types"; +import { TSecretVersionV2DALFactory } from "./secret-version-dal"; +import { TSecretVersionV2TagDALFactory } from "./secret-version-tag-dal"; + +type TSecretV2BridgeServiceFactoryDep = { + secretDAL: TSecretV2BridgeDALFactory; + secretVersionDAL: TSecretVersionV2DALFactory; + secretVersionTagDAL: Pick; + secretTagDAL: TSecretTagDALFactory; + permissionService: Pick; + projectEnvDAL: Pick; + folderDAL: Pick< + TSecretFolderDALFactory, + "findBySecretPath" | "updateById" | "findById" | "findByManySecretPath" | "find" + >; + secretImportDAL: Pick; + secretQueueService: Pick; + secretApprovalPolicyService: Pick; + secretApprovalRequestDAL: Pick; + secretApprovalRequestSecretDAL: Pick< + TSecretApprovalRequestSecretDALFactory, + "insertMany" | "insertApprovalSecretTags" + >; +}; + +export type TSecretV2BridgeServiceFactory = ReturnType; + +/* + * This service is a bridge from our old architecture towards the new architecture + */ +export const secretV2BridgeServiceFactory = ({ + secretDAL, + projectEnvDAL, + secretTagDAL, + secretVersionDAL, + folderDAL, + permissionService, + snapshotService, + secretQueueService, + secretImportDAL, + secretVersionTagDAL, + secretApprovalPolicyService, + secretApprovalRequestDAL, + secretApprovalRequestSecretDAL +}: TSecretV2BridgeServiceFactoryDep) => { + const createSecret = async ({ + actor, + actorId, + actorOrgId, + environment, + actorAuthMethod, + projectId, + secretPath, + ...inputSecret + }: TCreateSecretDTO) => { + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Create, + subject(ProjectPermissionSub.Secrets, { environment, secretPath }) + ); + + const folder = await folderDAL.findBySecretPath(projectId, environment, secretPath); + if (!folder) + throw new BadRequestError({ + message: "Folder not found for the given environment slug & secret path", + name: "Create secret" + }); + const folderId = folder.id; + + if (ActorType.USER !== actor && inputSecret.type === SecretType.Personal) { + throw new BadRequestError({ message: "Must be user to create personal secret" }); + } + + const doesSecretExist = await secretDAL.findOne({ + key: inputSecret.secretName, + type: SecretType.Shared, + folderId + }); + if (inputSecret.type === SecretType.Shared && doesSecretExist) + throw new BadRequestError({ message: "Secret already exist" }); + // if user creating personal check its shared also exist + if (inputSecret.type === SecretType.Personal && !doesSecretExist) { + throw new BadRequestError({ + message: "Failed to create personal secret override for no corresponding shared secret" + }); + } + + // validate tags + // fetch all tags and if not same count throw error meaning one was invalid tags + const tags = inputSecret.tagIds ? await secretTagDAL.find({ projectId, $in: { id: inputSecret.tagIds } }) : []; + if ((inputSecret.tagIds || []).length !== tags.length) throw new BadRequestError({ message: "Tag not found" }); + + const { secretName, type, ...el } = inputSecret; + const references = getAllNestedSecretReferences(inputSecret.secretValue); + const secret = await secretDAL.transaction((tx) => + fnSecretBulkInsert({ + folderId, + inputSecrets: [ + { + version: 1, + type, + reminderRepeatDays: el.secretReminderRepeatDays, + encryptedComment: el.secretComment ? Buffer.from(el.secretComment) : undefined, + encryptedValue: el.secretValue ? Buffer.from(el.secretValue) : undefined, + reminderNote: el.secretReminderNote, + skipMultilineEncoding: el.skipMultilineEncoding, + key: secretName, + userId: inputSecret.type === SecretType.Personal ? actorId : null, + tagIds: inputSecret.tagIds, + references + } + ], + secretDAL, + secretVersionDAL, + secretTagDAL, + secretVersionTagDAL, + tx + }) + ); + + // await snapshotService.performSnapshot(folderId); + await secretQueueService.syncSecrets({ + secretPath, + actorId, + actor, + projectId, + environmentSlug: folder.environment.slug + }); + + return reshapeBridgeSecret(projectId, environment, secretPath, { + ...secret[0], + value: inputSecret.secretValue, + comment: inputSecret.secretComment + }); + }; + + const updateSecret = async ({ + actor, + actorId, + actorOrgId, + environment, + actorAuthMethod, + projectId, + secretPath, + ...inputSecret + }: TUpdateSecretDTO) => { + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Edit, + subject(ProjectPermissionSub.Secrets, { environment, secretPath }) + ); + + if (inputSecret.newSecretName === "") { + throw new BadRequestError({ message: "New secret name cannot be empty" }); + } + + const folder = await folderDAL.findBySecretPath(projectId, environment, secretPath); + if (!folder) + throw new BadRequestError({ + message: "Folder not found for the given environment slug & secret path", + name: "UpdateSecret" + }); + const folderId = folder.id; + + if (ActorType.USER !== actor && inputSecret.type === SecretType.Personal) { + throw new BadRequestError({ message: "Must be user to create personal secret" }); + } + + if (inputSecret.newSecretName && inputSecret.type === SecretType.Personal) { + throw new BadRequestError({ message: "Personal secret cannot change the key name" }); + } + + let secret; + let secretId: string; + if (inputSecret.type === SecretType.Personal) { + const personalSecretToModify = await secretDAL.findOne({ + key: inputSecret.secretName, + type: SecretType.Personal, + folderId, + userId: actorId + }); + if (!personalSecretToModify) throw new BadRequestError({ message: "Secret not found" }); + secretId = personalSecretToModify.id; + secret = personalSecretToModify; + } else { + const sharedSecretToModify = await secretDAL.findOne({ + key: inputSecret.secretName, + type: SecretType.Shared, + folderId + }); + if (!sharedSecretToModify) throw new BadRequestError({ message: "Secret not found" }); + secretId = sharedSecretToModify.id; + secret = sharedSecretToModify; + } + + if (inputSecret.newSecretName) { + const doesNewNameSecretExist = await secretDAL.findOne({ + key: inputSecret.secretName, + type: SecretType.Shared, + folderId + }); + if (doesNewNameSecretExist) throw new BadRequestError({ message: "Secret with the new name already exist" }); + } + + // validate tags + // fetch all tags and if not same count throw error meaning one was invalid tags + const tags = inputSecret.tagIds ? await secretTagDAL.find({ projectId, $in: { id: inputSecret.tagIds } }) : []; + if ((inputSecret.tagIds || []).length !== tags.length) throw new BadRequestError({ message: "Tag not found" }); + + const { secretName, secretValue, secretComment } = inputSecret; + + const updatedSecret = await secretDAL.transaction(async (tx) => + fnSecretBulkUpdate({ + folderId, + inputSecrets: [ + { + filter: { id: secretId }, + data: { + reminderRepeatDays: inputSecret.secretReminderRepeatDays, + encryptedComment: secretComment ? Buffer.from(secretComment) : undefined, + reminderNote: inputSecret.secretReminderNote, + skipMultilineEncoding: inputSecret.skipMultilineEncoding, + key: inputSecret.newSecretName || secretName, + tags: inputSecret.tagIds, + ...(secretValue + ? { + encryptedValue: Buffer.from(secretValue), + references: getAllNestedSecretReferences(secretValue) + } + : { + encryptedValue: undefined, + references: undefined + }) + } + } + ], + secretDAL, + secretVersionDAL, + secretTagDAL, + secretVersionTagDAL, + tx + }) + ); + await secretQueueService.handleSecretReminder({ + newSecret: { + id: updatedSecret[0].id, + ...inputSecret + }, + oldSecret: secret, + projectId + }); + + // await snapshotService.performSnapshot(folderId); + await secretQueueService.syncSecrets({ + actor, + actorId, + secretPath, + projectId, + environmentSlug: folder.environment.slug + }); + return reshapeBridgeSecret(projectId, environment, secretPath, { + ...updatedSecret[0], + // TODO(akhilmhdh-sev2): fix this + value: updatedSecret[0].encryptedValue?.toString(), + comment: updatedSecret[0].encryptedComment?.toString() + }); + }; + + const deleteSecret = async ({ + actor, + actorId, + actorOrgId, + actorAuthMethod, + environment, + projectId, + secretPath, + ...inputSecret + }: TDeleteSecretDTO) => { + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Delete, + subject(ProjectPermissionSub.Secrets, { environment, secretPath }) + ); + + const folder = await folderDAL.findBySecretPath(projectId, environment, secretPath); + if (!folder) + throw new BadRequestError({ + message: "Folder not found for the given environment slug & secret path", + name: "Create secret" + }); + const folderId = folder.id; + + if (ActorType.USER !== actor && inputSecret.type === SecretType.Personal) { + throw new BadRequestError({ message: "Must be user to create personal secret" }); + } + + const deletedSecret = await secretDAL.transaction(async (tx) => + fnSecretBulkDelete({ + projectId, + folderId, + actorId, + secretDAL, + secretQueueService, + inputSecrets: [ + { + type: inputSecret.type as SecretType, + secretKey: inputSecret.secretName + } + ], + tx + }) + ); + + // await snapshotService.performSnapshot(folderId); + await secretQueueService.syncSecrets({ + actor, + actorId, + secretPath, + projectId, + environmentSlug: folder.environment.slug + }); + return reshapeBridgeSecret(projectId, environment, secretPath, { + ...deletedSecret[0], + // TODO(akhilmhdh-sev2): fix this + value: deletedSecret[0].encryptedValue?.toString(), + comment: deletedSecret[0].encryptedComment?.toString() + }); + }; + + const getSecrets = async ({ + actorId, + path, + environment, + projectId, + actor, + actorOrgId, + actorAuthMethod, + includeImports, + recursive // TODO(akhilmhdh-sev2): add logic for expandSecretReferences + }: TGetSecretsDTO) => { + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId + ); + + let paths: { folderId: string; path: string }[] = []; + + if (recursive) { + const deepPaths = await recursivelyGetSecretPaths({ + folderDAL, + projectEnvDAL, + projectId, + environment, + currentPath: path, + hasAccess: (permissionEnvironment, permissionSecretPath) => + permission.can( + ProjectPermissionActions.Read, + subject(ProjectPermissionSub.Secrets, { + environment: permissionEnvironment, + secretPath: permissionSecretPath + }) + ) + }); + + if (!deepPaths) return { secrets: [], imports: [] }; + + paths = deepPaths.map(({ folderId, path: p }) => ({ folderId, path: p })); + } else { + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Read, + subject(ProjectPermissionSub.Secrets, { environment, secretPath: path }) + ); + + const folder = await folderDAL.findBySecretPath(projectId, environment, path); + if (!folder) return { secrets: [], imports: [] }; + + paths = [{ folderId: folder.id, path }]; + } + + const groupedPaths = groupBy(paths, (p) => p.folderId); + + const secrets = await secretDAL.findByFolderIds( + paths.map((p) => p.folderId), + actorId + ); + + if (includeImports) { + const secretImports = await secretImportDAL.findByFolderIds(paths.map((p) => p.folderId)); + const allowedImports = secretImports.filter(({ importEnv, importPath, isReplication }) => + !isReplication && + // if its service token allow full access over imported one + actor === ActorType.SERVICE + ? true + : permission.can( + ProjectPermissionActions.Read, + subject(ProjectPermissionSub.Secrets, { + environment: importEnv.slug, + secretPath: importPath + }) + ) + ); + const importedSecrets = await fnSecretsV2FromImports({ + allowedImports, + secretDAL, + folderDAL, + secretImportDAL + }); + + return { + secrets: secrets.map((secret) => + reshapeBridgeSecret(projectId, environment, groupedPaths[secret.folderId][0].path, { + ...secret, + // TODO(akhilmhdh-sev2): decryption missiong + value: secret.encryptedValue?.toString(), + comment: secret.encryptedComment?.toString() + }) + ), + imports: importedSecrets + }; + } + + return { + secrets: secrets.map((secret) => + reshapeBridgeSecret(projectId, environment, groupedPaths[secret.folderId][0].path, { + ...secret, + // TODO(akhilmhdh-sev2): decrypt this + value: secret.encryptedValue?.toString(), + comment: secret.encryptedComment?.toString() + }) + ) + }; + }; + + const getSecretByName = async ({ + actorId, + actor, + actorOrgId, + actorAuthMethod, + projectId, + environment, + path, + type, + secretName, + version, + includeImports, + expandSecretReferences + }: TGetASecretDTO) => { + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Read, + subject(ProjectPermissionSub.Secrets, { environment, secretPath: path }) + ); + const folder = await folderDAL.findBySecretPath(projectId, environment, path); + if (!folder) + throw new BadRequestError({ + message: "Folder not found for the given environment slug & secret path", + name: "Create secret" + }); + const folderId = folder.id; + + let secretType = type; + if (actor === ActorType.SERVICE) { + logger.info( + `secretServiceFactory: overriding secret type for service token [projectId=${projectId}] [factoryFunctionName=getSecretByName]` + ); + secretType = SecretType.Shared; + } + + const secret = await (version === undefined + ? secretDAL.findOneWithTags({ + folderId, + type: secretType, + key: secretName, + userId: secretType === SecretType.Personal ? actorId : null + }) + : secretVersionDAL + .findOne({ + folderId, + type: secretType, + userId: secretType === SecretType.Personal ? actorId : null, + key: secretName + }) + .then((el) => SecretsV2Schema.parse({ ...el, id: el.secretId }))); + // TODO(akhilmhdh-sev2): resolve this decryptSecret + const interpolateInlineSecretReference = interpolateSecrets({ + projectId, + decryptSecret: () => "", + secretDAL, + folderDAL + }); + // now if secret is not found + // then search for imported secrets + // here we consider the import order also thus starting from bottom + if (!secret && includeImports) { + const secretImports = await secretImportDAL.find({ folderId, isReplication: false }); + const allowedImports = secretImports.filter(({ importEnv, importPath }) => + // if its service token allow full access over imported one + actor === ActorType.SERVICE + ? true + : permission.can( + ProjectPermissionActions.Read, + subject(ProjectPermissionSub.Secrets, { + environment: importEnv.slug, + secretPath: importPath + }) + ) + ); + const importedSecrets = await fnSecretsV2FromImports({ + allowedImports, + secretDAL, + folderDAL, + secretImportDAL + }); + for (let i = importedSecrets.length - 1; i >= 0; i -= 1) { + for (let j = 0; j < importedSecrets[i].secrets.length; j += 1) { + if (secretName === importedSecrets[i].secrets[j].key) { + const importedSecret = importedSecrets[i].secrets[j]; + let secretValue = importedSecret.encryptedValue ? importedSecret.encryptedValue.toString() : undefined; + if (expandSecretReferences && secretValue) { + const secretReferenceExpandedString = { + [importedSecret.key]: { value: secretValue } + }; + // eslint-disable-next-line + await interpolateInlineSecretReference(secretReferenceExpandedString); + secretValue = secretReferenceExpandedString[importedSecret.key].value; + } + + // TODO(akhilmhdh-sev2): decrypt this + return reshapeBridgeSecret(projectId, importedSecrets[i].environment, importedSecrets[i].secretPath, { + ...importedSecret, + value: secretValue, + comment: importedSecret.encryptedComment?.toString() + }); + } + } + } + } + if (!secret) throw new BadRequestError({ message: "Secret not found" }); + + let secretValue = secret.encryptedValue ? secret.encryptedValue.toString() : undefined; + if (expandSecretReferences && secretValue) { + const secretReferenceExpandedString = { + [secret.key]: { value: secretValue } + }; + // eslint-disable-next-line + await interpolateInlineSecretReference(secretReferenceExpandedString); + secretValue = secretReferenceExpandedString[secret.key].value; + } + + // TODO(akhilmhdh-sev2): fix this + return reshapeBridgeSecret(projectId, environment, path, { + ...secret, + value: secretValue, + comment: secret.encryptedComment?.toString() + }); + }; + + const createManySecret = async ({ + secretPath, + actor, + actorId, + actorAuthMethod, + actorOrgId, + environment, + projectId, + secrets: inputSecrets + }: TCreateManySecretDTO) => { + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Create, + subject(ProjectPermissionSub.Secrets, { environment, secretPath }) + ); + + const folder = await folderDAL.findBySecretPath(projectId, environment, secretPath); + if (!folder) + throw new BadRequestError({ + message: "Folder not found for the given environment slug & secret path", + name: "Create secret" + }); + const folderId = folder.id; + + const secrets = await secretDAL.findBySecretKeys( + folderId, + inputSecrets.map((el) => ({ + key: el.secretKey, + type: SecretType.Shared + })) + ); + if (secrets.length) + throw new BadRequestError({ message: `Secret already exist: ${secrets.map((el) => el.key).join(",")}` }); + + // 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 BadRequestError({ message: "Tag not found" }); + + const newSecrets = await secretDAL.transaction(async (tx) => + fnSecretBulkInsert({ + inputSecrets: inputSecrets.map((el) => ({ + version: 1, + encryptedComment: el.secretComment ? Buffer.from(el.secretComment) : undefined, + encryptedValue: el.secretValue ? Buffer.from(el.secretValue) : undefined, + skipMultilineEncoding: el.skipMultilineEncoding, + key: el.secretKey, + tagIds: el.tagIds, + references: getAllNestedSecretReferences(el.secretValue), + type: SecretType.Shared + })), + folderId, + secretDAL, + secretVersionDAL, + secretTagDAL, + secretVersionTagDAL, + tx + }) + ); + + // await snapshotService.performSnapshot(folderId); + await secretQueueService.syncSecrets({ + actor, + actorId, + secretPath, + projectId, + environmentSlug: folder.environment.slug + }); + + return newSecrets.map((el) => + reshapeBridgeSecret(projectId, environment, secretPath, { + ...el, + // TODO(akhilmhdh-sev2): decryption missiong + value: el.encryptedValue?.toString(), + comment: el.encryptedComment?.toString() + }) + ); + }; + + const updateManySecret = async ({ + actor, + actorId, + actorOrgId, + actorAuthMethod, + environment, + projectId, + secretPath, + secrets: inputSecrets + }: TUpdateManySecretDTO) => { + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Edit, + subject(ProjectPermissionSub.Secrets, { environment, secretPath }) + ); + + const folder = await folderDAL.findBySecretPath(projectId, environment, secretPath); + if (!folder) + throw new BadRequestError({ + message: "Folder not found for the given environment slug & secret path", + name: "Update secret" + }); + const folderId = folder.id; + + const secretsToUpdate = await secretDAL.findBySecretKeys( + folderId, + inputSecrets.map((el) => ({ + key: el.secretKey, + type: SecretType.Shared + })) + ); + if (secretsToUpdate.length !== inputSecrets.length) + throw new BadRequestError({ message: `Secret not exist: ${secretsToUpdate.map((el) => el.key).join(",")}` }); + + // 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.findBySecretKeys( + folderId, + secretsWithNewName.map((el) => ({ + key: el.secretKey, + type: SecretType.Shared + })) + ); + if (secrets.length) + throw new BadRequestError({ message: `Secret not exist: ${secretsToUpdate.map((el) => el.key).join(",")}` }); + } + + // 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 BadRequestError({ message: "Tag not found" }); + + const secrets = await secretDAL.transaction(async (tx) => + fnSecretBulkUpdate({ + folderId, + tx, + inputSecrets: inputSecrets.map((el) => ({ + filter: { key: el.secretKey, type: SecretType.Shared }, + data: { + reminderRepeatDays: el.secretReminderRepeatDays, + encryptedComment: el.secretComment ? Buffer.from(el.secretComment) : undefined, + reminderNote: el.secretReminderNote, + skipMultilineEncoding: el.skipMultilineEncoding, + key: el.newSecretName || el.secretKey, + tags: el.tagIds, + ...(el.secretValue + ? { + encryptedValue: Buffer.from(el.secretValue), + references: getAllNestedSecretReferences(el.secretValue) + } + : { + encryptedValue: undefined, + references: undefined + }) + } + })), + secretDAL, + secretVersionDAL, + secretTagDAL, + secretVersionTagDAL + }) + ); + + // await snapshotService.performSnapshot(folderId); + await secretQueueService.syncSecrets({ + actor, + actorId, + secretPath, + projectId, + environmentSlug: folder.environment.slug + }); + + return secrets.map((el) => + reshapeBridgeSecret(projectId, environment, secretPath, { + ...el, + // TODO(akhilmhdh-sev2): decryption missiong + value: el.encryptedValue?.toString(), + comment: el.encryptedComment?.toString() + }) + ); + }; + + const deleteManySecret = async ({ + secrets: inputSecrets, + secretPath, + environment, + projectId, + actor, + actorId, + actorAuthMethod, + actorOrgId + }: TDeleteManySecretDTO) => { + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Delete, + subject(ProjectPermissionSub.Secrets, { environment, secretPath }) + ); + + const folder = await folderDAL.findBySecretPath(projectId, environment, secretPath); + if (!folder) + throw new BadRequestError({ + message: "Folder not found for the given environment slug & secret path", + name: "Create secret" + }); + const folderId = folder.id; + + const secretsToDelete = await secretDAL.findBySecretKeys( + folderId, + inputSecrets.map((el) => ({ + key: el.secretKey, + type: SecretType.Shared + })) + ); + if (secretsToDelete.length !== inputSecrets.length) + throw new BadRequestError({ message: `Secret not exist: ${secretsToDelete.map((el) => el.key).join(",")}` }); + + const secretsDeleted = await secretDAL.transaction(async (tx) => + fnSecretBulkDelete({ + secretDAL, + secretQueueService, + inputSecrets: inputSecrets.map(({ type, secretKey }) => ({ + secretKey, + type: type || SecretType.Shared + })), + projectId, + folderId, + actorId, + tx + }) + ); + + // await snapshotService.performSnapshot(folderId); + await secretQueueService.syncSecrets({ + actor, + actorId, + secretPath, + projectId, + environmentSlug: folder.environment.slug + }); + + return secretsDeleted.map((el) => + reshapeBridgeSecret(projectId, environment, secretPath, { + ...el, + // TODO(akhilmhdh-sev2): decryption missiong + value: el.encryptedValue?.toString(), + comment: el.encryptedComment?.toString() + }) + ); + }; + + const getSecretVersions = async ({ + actorId, + actor, + actorOrgId, + actorAuthMethod, + limit = 20, + offset = 0, + secretId + }: TGetSecretVersionsDTO) => { + const secret = await secretDAL.findById(secretId); + if (!secret) throw new BadRequestError({ message: "Failed to find secret" }); + + const folder = await folderDAL.findById(secret.folderId); + if (!folder) throw new BadRequestError({ message: "Failed to find secret" }); + + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + folder.projectId, + actorAuthMethod, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.SecretRollback); + + const secretVersions = await secretVersionDAL.find({ secretId }, { offset, limit, sort: [["createdAt", "desc"]] }); + return secretVersions; + }; + + // this is a backfilling API for secret references + // what it does is it will go through all the secret values and parse all references + // populate the secret reference to do sync integrations + const backfillSecretReferences = async ({ + projectId, + actor, + actorId, + actorOrgId, + actorAuthMethod + }: TBackFillSecretReferencesDTO) => { + const { hasRole } = await permissionService.getProjectPermission( + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId + ); + + if (!hasRole(ProjectMembershipRole.Admin)) + throw new BadRequestError({ message: "Only admins are allowed to take this action" }); + + await secretDAL.transaction(async (tx) => { + const secrets = await secretDAL.findAllProjectSecretValues(projectId, tx); + // TODO(akhilmhdh-sev2): decryption missing + await secretDAL.upsertSecretReferences( + secrets + .filter((el) => Boolean(el.encryptedValue)) + .map(({ id, encryptedValue }) => ({ + secretId: id, + references: getAllNestedSecretReferences(encryptedValue?.toString("utf8")) + })), + tx + ); + }); + + return { message: "Successfully backfilled secret references" }; + }; + + const moveSecrets = async ({ + sourceEnvironment, + sourceSecretPath, + destinationEnvironment, + destinationSecretPath, + secretIds, + projectId, + shouldOverwrite, + actor, + actorId, + actorAuthMethod, + actorOrgId + }: TMoveSecretsDTO) => { + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + projectId, + 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 sourceFolder = await folderDAL.findBySecretPath(projectId, sourceEnvironment, sourceSecretPath); + if (!sourceFolder) { + throw new NotFoundError({ + message: "Source path does not exist." + }); + } + + const destinationFolder = await folderDAL.findBySecretPath( + projectId, + 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, + // TODO(akhilmhdh-sev2): decryption missiong + value: secret.encryptedValue?.toString() + })); + + 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, + // TODO(akhilmhdh-sev2): decryption missiong + value: secret.encryptedValue?.toString() + }; + }); + + const destinationSecretsGroupedByKey = groupBy(decryptedDestinationSecrets, (i) => i.key); + + const locallyCreatedSecrets = decryptedSourceSecrets + .filter(({ key }) => !destinationSecretsGroupedByKey[key]?.[0]) + .map((el) => ({ ...el, operation: SecretOperations.Create })); + + const locallyUpdatedSecrets = decryptedSourceSecrets + .filter( + ({ key, value }) => + destinationSecretsGroupedByKey[key]?.[0] && destinationSecretsGroupedByKey[key]?.[0]?.value !== value + ) + .map((el) => ({ ...el, operation: SecretOperations.Update })); + + if (locallyUpdatedSecrets.length > 0 && !shouldOverwrite) { + const existingKeys = locallyUpdatedSecrets.map((s) => s.key); + + 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( + projectId, + 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 + ); + + // TODO(akhilmhdh-sev2): fix this + // const commits = locallyCreatedSecrets.concat(locallyUpdatedSecrets).map((doc) => { + // const { operation } = doc; + // const localSecret = destinationSecretsGroupedByKey[doc.key]?.[0]; + // + // return { + // op: operation, + // 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([], tx); + } else { + // apply changes directly + if (locallyCreatedSecrets.length) { + await fnSecretBulkInsert({ + folderId: destinationFolder.id, + secretVersionDAL, + secretDAL, + tx, + secretTagDAL, + secretVersionTagDAL, + inputSecrets: locallyCreatedSecrets.map((doc) => { + return { + type: doc.type, + metadata: doc.metadata, + key: doc.key, + encryptedValue: doc.encryptedValue ? Buffer.from(doc.encryptedValue) : undefined, + encryptedComment: doc.encryptedComment ? Buffer.from(doc.encryptedComment) : undefined, + skipMultilineEncoding: doc.skipMultilineEncoding, + reminderNote: doc.reminderNote, + reminderRepeatDays: doc.reminderRepeatDays, + references: doc.value ? getAllNestedSecretReferences(doc.value) : [] + }; + }) + }); + } + if (locallyUpdatedSecrets.length) { + await fnSecretBulkUpdate({ + folderId: destinationFolder.id, + secretVersionDAL, + secretDAL, + tx, + secretTagDAL, + secretVersionTagDAL, + inputSecrets: locallyUpdatedSecrets.map((doc) => { + return { + filter: { + folderId: destinationFolder.id, + id: destinationSecretsGroupedByKey[doc.key][0].id + }, + data: { + metadata: doc.metadata, + key: doc.key, + encryptedComment: doc.encryptedComment, + skipMultilineEncoding: doc.skipMultilineEncoding, + reminderNote: doc.reminderNote, + reminderRepeatDays: doc.reminderRepeatDays, + ...(doc.encryptedValue + ? { + encryptedValue: doc.encryptedValue, + // TODO(akhilmhdh-sev2): fix decryption + references: getAllNestedSecretReferences(doc.encryptedValue.toString()) + } + : { + encryptedValue: undefined, + references: undefined + }) + } + }; + }) + }); + } + + isDestinationUpdated = true; + } + + // Next step is to delete the secrets from the source folder: + const sourceSecretsGroupByKey = groupBy(sourceSecrets, (i) => i.key); + const locallyDeletedSecrets = decryptedSourceSecrets.map((el) => ({ ...el, operation: SecretOperations.Delete })); + + const sourceFolderPolicy = await secretApprovalPolicyService.getSecretApprovalPolicy( + projectId, + 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 + ); + + // TODO(akhilmhdh-sev2): finish this + // const commits = locallyDeletedSecrets.map((doc) => { + // const { operation } = doc; + // const localSecret = sourceSecretsGroupByKey[doc.key]?.[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([], 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, + secretPath: destinationFolder.path, + environmentSlug: destinationFolder.environment.slug, + actorId, + actor + }); + } + + if (isSourceUpdated) { + // await snapshotService.performSnapshot(sourceFolder.id); + await secretQueueService.syncSecrets({ + projectId, + secretPath: sourceFolder.path, + environmentSlug: sourceFolder.environment.slug, + actorId, + actor + }); + } + + return { + projectId, + isSourceUpdated, + isDestinationUpdated + }; + }; + + return { + createSecret, + deleteSecret, + updateSecret, + createManySecret, + updateManySecret, + deleteManySecret, + getSecretByName, + getSecrets, + getSecretVersions, + backfillSecretReferences, + moveSecrets + }; +}; 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 new file mode 100644 index 000000000..1964aac05 --- /dev/null +++ b/backend/src/services/secret-v2-bridge/secret-v2-bridge-types.ts @@ -0,0 +1,267 @@ +import { Knex } from "knex"; + +import { SecretType, TSecretsV2, TSecretsV2Insert, TSecretsV2Update } from "@app/db/schemas"; +import { TProjectPermission } from "@app/lib/types"; +import { TProjectDALFactory } from "@app/services/project/project-dal"; +import { TSecretFolderDALFactory } from "@app/services/secret-folder/secret-folder-dal"; +import { TSecretTagDALFactory } from "@app/services/secret-tag/secret-tag-dal"; + +import { TSecretV2BridgeDALFactory } from "./secret-v2-bridge-dal"; +import { TSecretVersionV2DALFactory } from "./secret-version-dal"; +import { TSecretVersionV2TagDALFactory } from "./secret-version-tag-dal"; + +type TPartialSecret = Pick; + +type TPartialInputSecret = Pick; + +export type TGetSecretsDTO = { + expandSecretReferences?: boolean; + path: string; + environment: string; + includeImports?: boolean; + recursive?: boolean; +} & TProjectPermission; + +export type TGetASecretDTO = { + secretName: string; + path: string; + environment: string; + expandSecretReferences?: boolean; + type: "shared" | "personal"; + includeImports?: boolean; + version?: number; + projectId: string; +} & Omit; + +export type TCreateSecretDTO = TProjectPermission & { + secretName: string; + secretPath: string; + environment: string; + secretValue: string; + type: SecretType; + tagIds?: string[]; + secretComment?: string; + skipMultilineEncoding?: boolean; + secretReminderRepeatDays?: number | null; + secretReminderNote?: string | null; +}; + +export type TUpdateSecretDTO = TProjectPermission & { + secretPath: string; + environment: string; + secretName: string; + secretValue?: string; + newSecretName?: string; + secretComment?: string; + type: SecretType; + tagIds?: string[]; + skipMultilineEncoding?: boolean; + secretReminderRepeatDays?: number | null; + secretReminderNote?: string | null; + metadata?: { + source?: string; + }; +}; + +export type TDeleteSecretDTO = TProjectPermission & { + secretPath: string; + environment: string; + secretName: string; + type: SecretType; +}; + +export type TCreateManySecretDTO = Omit & { + secretPath: string; + projectId: string; + environment: string; + secrets: { + secretKey: string; + secretValue: string; + secretComment?: string; + skipMultilineEncoding?: boolean; + tagIds?: string[]; + metadata?: { + source?: string; + }; + }[]; +}; + +export type TUpdateManySecretDTO = Omit & { + secretPath: string; + projectId: string; + environment: string; + secrets: { + secretKey: string; + newSecretName?: string; + secretValue: string; + secretComment?: string; + skipMultilineEncoding?: boolean; + tagIds?: string[]; + secretReminderRepeatDays?: number | null; + secretReminderNote?: string | null; + }[]; +}; + +export type TDeleteManySecretDTO = Omit & { + secretPath: string; + projectId: string; + environment: string; + secrets: { + secretKey: string; + type?: SecretType; + }[]; +}; + +export type TGetSecretVersionsDTO = Omit & { + limit?: number; + offset?: number; + secretId: string; +}; + +export type TSecretReference = { environment: string; secretPath: string; secretKey: string }; + +export type TFnSecretBulkInsert = { + folderId: string; + tx?: Knex; + inputSecrets: Array & { tagIds?: string[]; references: TSecretReference[] }>; + secretDAL: Pick; + secretVersionDAL: Pick; + secretTagDAL: Pick; + secretVersionTagDAL: Pick; +}; + +type TRequireReferenceIfValue = + | (Omit & { + encryptedValue: Buffer | null; + references: TSecretReference[]; + }) + | (Omit & { + encryptedValue?: never; + references?: never; + }); + +export type TFnSecretBulkUpdate = { + folderId: string; + inputSecrets: { + filter: Partial; + data: TRequireReferenceIfValue & { tags?: string[] }; + }[]; + secretDAL: Pick; + secretVersionDAL: Pick; + secretTagDAL: Pick; + secretVersionTagDAL: Pick; + tx?: Knex; +}; + +export type TFnSecretBulkDelete = { + folderId: string; + projectId: string; + inputSecrets: Array<{ type: SecretType; secretKey: string }>; + actorId: string; + tx?: Knex; + secretDAL: Pick; + secretQueueService: { + removeSecretReminder: (data: TRemoveSecretReminderDTO) => Promise; + }; +}; + +export type THandleReminderDTO = { + newSecret: TPartialInputSecret; + oldSecret: TPartialSecret; + projectId: string; +}; + +export type TCreateSecretReminderDTO = { + oldSecret: TPartialSecret; + newSecret: TPartialSecret; + projectId: string; +}; + +export type TRemoveSecretReminderDTO = { + secretId: string; + repeatDays: number; +}; + +export type TBackFillSecretReferencesDTO = TProjectPermission; + +export type TCreateManySecretsFnFactory = { + projectDAL: TProjectDALFactory; + secretDAL: TSecretV2BridgeDALFactory; + secretVersionDAL: TSecretVersionV2DALFactory; + secretTagDAL: TSecretTagDALFactory; + secretVersionTagDAL: TSecretVersionV2TagDALFactory; + folderDAL: TSecretFolderDALFactory; +}; + +export type TCreateManySecretsFn = { + projectId: string; + environment: string; + path: string; + secrets: { + secretName: string; + secretValue: string; + type: SecretType; + secretComment?: string; + skipMultilineEncoding?: boolean; + tags?: string[]; + metadata?: { + source?: string; + }; + }[]; + userId?: string; // only relevant for personal secret(s) +}; + +export type TUpdateManySecretsFnFactory = { + projectDAL: TProjectDALFactory; + secretDAL: TSecretV2BridgeDALFactory; + secretVersionDAL: TSecretVersionV2DALFactory; + secretTagDAL: TSecretTagDALFactory; + secretVersionTagDAL: TSecretVersionV2TagDALFactory; + folderDAL: TSecretFolderDALFactory; +}; + +export type TUpdateManySecretsFn = { + projectId: string; + environment: string; + path: string; + secrets: { + secretName: string; + newSecretName?: string; + secretValue: string; + type: SecretType; + secretComment?: string; + skipMultilineEncoding?: boolean; + secretReminderRepeatDays?: number | null; + secretReminderNote?: string | null; + tags?: string[]; + metadata?: { + source?: string; + }; + }[]; + userId?: string; +}; + +export enum SecretOperations { + Create = "create", + Update = "update", + Delete = "delete" +} + +export type TMoveSecretsDTO = { + projectId: string; + sourceEnvironment: string; + sourceSecretPath: string; + destinationEnvironment: string; + destinationSecretPath: string; + secretIds: string[]; + shouldOverwrite: boolean; +} & Omit; + +export type TAttachSecretTagsDTO = { + projectId: string; + secretName: string; + tagSlugs: string[]; + environment: string; + secretPath: string; + type: SecretType; +} & Omit; diff --git a/backend/src/services/secret-v2-bridge/secret-version-dal.ts b/backend/src/services/secret-v2-bridge/secret-version-dal.ts new file mode 100644 index 000000000..e4922a57d --- /dev/null +++ b/backend/src/services/secret-v2-bridge/secret-version-dal.ts @@ -0,0 +1,124 @@ +import { Knex } from "knex"; + +import { TDbClient } from "@app/db"; +import { TableName, TSecretVersionsV2, TSecretVersionsV2Update } from "@app/db/schemas"; +import { BadRequestError, DatabaseError } from "@app/lib/errors"; +import { ormify, selectAllTableCols } from "@app/lib/knex"; + +export type TSecretVersionV2DALFactory = ReturnType; + +export const secretVersionV2BridgeDALFactory = (db: TDbClient) => { + const secretVersionV2Orm = ormify(db, TableName.SecretVersionV2); + + // This will fetch all latest secret versions from a folder + const findLatestVersionByFolderId = async (folderId: string, tx?: Knex) => { + try { + const docs = await (tx || db.replicaNode())(TableName.SecretVersionV2) + .where(`${TableName.SecretVersionV2}.folderId`, folderId) + .join(TableName.SecretV2, `${TableName.SecretV2}.id`, `${TableName.SecretVersionV2}.secretId`) + .join( + (tx || db)(TableName.SecretVersionV2) + .groupBy("folderId", "secretId") + .max("version") + .select("secretId") + .as("latestVersion"), + (bd) => { + bd.on(`${TableName.SecretVersionV2}.secretId`, "latestVersion.secretId").andOn( + `${TableName.SecretVersionV2}.version`, + "latestVersion.max" + ); + } + ) + .select(selectAllTableCols(TableName.SecretVersionV2)); + return docs; + } catch (error) { + throw new DatabaseError({ error, name: "FindLatestVersionByFolderId" }); + } + }; + + const bulkUpdate = async ( + data: Array<{ filter: Partial; data: TSecretVersionsV2Update }>, + tx?: Knex + ) => { + try { + const secs = await Promise.all( + data.map(async ({ filter, data: updateData }) => { + const [doc] = await (tx || db)(TableName.SecretVersionV2) + .where(filter) + .update(updateData) + .increment("version", 1) // TODO: Is this really needed? + .returning("*"); + if (!doc) throw new BadRequestError({ message: "Failed to update document" }); + return doc; + }) + ); + return secs; + } catch (error) { + throw new DatabaseError({ error, name: "bulk update secret" }); + } + }; + + const findLatestVersionMany = async (folderId: string, secretIds: string[], tx?: Knex) => { + try { + if (!secretIds.length) return {}; + const docs: Array = await (tx || db.replicaNode())(TableName.SecretVersionV2) + .where("folderId", folderId) + .whereIn(`${TableName.SecretVersionV2}.secretId`, secretIds) + .join( + (tx || db)(TableName.SecretVersionV2) + .groupBy("secretId") + .max("version") + .select("secretId") + .as("latestVersion"), + (bd) => { + bd.on(`${TableName.SecretVersionV2}.secretId`, "latestVersion.secretId").andOn( + `${TableName.SecretVersionV2}.version`, + "latestVersion.max" + ); + } + ); + return docs.reduce>( + (prev, curr) => ({ ...prev, [curr.secretId || ""]: curr }), + {} + ); + } catch (error) { + throw new DatabaseError({ error, name: "FindLatestVersinMany" }); + } + }; + + const pruneExcessVersions = async () => { + try { + await db(TableName.SecretVersionV2) + .with("version_cte", (qb) => { + void qb + .from(TableName.SecretVersionV2) + .select( + "id", + "folderId", + db.raw( + `ROW_NUMBER() OVER (PARTITION BY ${TableName.SecretVersionV2}."secretId" ORDER BY ${TableName.SecretVersionV2}."createdAt" DESC) AS row_num` + ) + ); + }) + .join(TableName.SecretFolder, `${TableName.SecretFolder}.id`, `${TableName.SecretVersionV2}.folderId`) + .join(TableName.Environment, `${TableName.Environment}.id`, `${TableName.SecretFolder}.envId`) + .join(TableName.Project, `${TableName.Project}.id`, `${TableName.Environment}.projectId`) + .join("version_cte", "version_cte.id", `${TableName.SecretVersionV2}.id`) + .whereRaw(`version_cte.row_num > ${TableName.Project}."pitVersionLimit"`) + .delete(); + } catch (error) { + throw new DatabaseError({ + error, + name: "Secret Version Prune" + }); + } + }; + + return { + ...secretVersionV2Orm, + pruneExcessVersions, + findLatestVersionMany, + bulkUpdate, + findLatestVersionByFolderId + }; +}; diff --git a/backend/src/services/secret-v2-bridge/secret-version-tag-dal.ts b/backend/src/services/secret-v2-bridge/secret-version-tag-dal.ts new file mode 100644 index 000000000..f1a3531e9 --- /dev/null +++ b/backend/src/services/secret-v2-bridge/secret-version-tag-dal.ts @@ -0,0 +1,10 @@ +import { TDbClient } from "@app/db"; +import { TableName } from "@app/db/schemas"; +import { ormify } from "@app/lib/knex"; + +export type TSecretVersionV2TagDALFactory = ReturnType; + +export const secretVersionV2TagBridgeDALFactory = (db: TDbClient) => { + const secretVersionTagDAL = ormify(db, TableName.SecretVersionV2Tag); + return secretVersionTagDAL; +}; diff --git a/backend/src/services/secret/secret-dal.ts b/backend/src/services/secret/secret-dal.ts index c26880e38..aac01d393 100644 --- a/backend/src/services/secret/secret-dal.ts +++ b/backend/src/services/secret/secret-dal.ts @@ -20,8 +20,6 @@ export const secretDALFactory = (db: TDbClient) => { } }; - // the idea is to use postgres specific function - // insert with id this will cause a conflict then merge the data const bulkUpdate = async ( data: Array<{ filter: Partial; data: TSecretsUpdate }>, diff --git a/backend/src/services/secret/secret-fns.ts b/backend/src/services/secret/secret-fns.ts index 419a41b10..d4ec6cc08 100644 --- a/backend/src/services/secret/secret-fns.ts +++ b/backend/src/services/secret/secret-fns.ts @@ -689,7 +689,7 @@ export const createManySecretsRawFnFactory = ({ secrets, userId }: TCreateManySecretsRawFn) => { - const botKey = await getBotKeyFn(projectId); + const { botKey } = await getBotKeyFn(projectId); if (!botKey) throw new BadRequestError({ message: "Project bot not found", name: "bot_not_found_error" }); await projectDAL.checkProjectUpgradeStatus(projectId); @@ -787,7 +787,7 @@ export const updateManySecretsRawFnFactory = ({ secrets, // consider accepting instead ciphertext secrets userId }: TUpdateManySecretsRawFn): Promise> => { - const botKey = await getBotKeyFn(projectId); + const { botKey } = await getBotKeyFn(projectId); if (!botKey) throw new BadRequestError({ message: "Project bot not found", name: "bot_not_found_error" }); await projectDAL.checkProjectUpgradeStatus(projectId); diff --git a/backend/src/services/secret/secret-service.ts b/backend/src/services/secret/secret-service.ts index 39b9ea332..ade727c99 100644 --- a/backend/src/services/secret/secret-service.ts +++ b/backend/src/services/secret/secret-service.ts @@ -35,6 +35,7 @@ import { TSecretFolderDALFactory } from "../secret-folder/secret-folder-dal"; import { TSecretImportDALFactory } from "../secret-import/secret-import-dal"; import { fnSecretsFromImports } from "../secret-import/secret-import-fns"; import { TSecretTagDALFactory } from "../secret-tag/secret-tag-dal"; +import { TSecretV2BridgeServiceFactory } from "../secret-v2-bridge/secret-v2-bridge-service"; import { TSecretDALFactory } from "./secret-dal"; import { decryptSecretRaw, @@ -83,6 +84,7 @@ type TSecretServiceFactoryDep = { TSecretFolderDALFactory, "findBySecretPath" | "updateById" | "findById" | "findByManySecretPath" | "find" >; + secretV2BridgeService: TSecretV2BridgeServiceFactory; secretBlindIndexDAL: TSecretBlindIndexDALFactory; permissionService: Pick; snapshotService: Pick; @@ -115,19 +117,20 @@ export const secretServiceFactory = ({ secretVersionTagDAL, secretApprovalPolicyService, secretApprovalRequestDAL, - secretApprovalRequestSecretDAL + secretApprovalRequestSecretDAL, + secretV2BridgeService }: TSecretServiceFactoryDep) => { const getSecretReference = async (projectId: string) => { // if bot key missing means e2e still exist - const botKey = await projectBotService.getBotKey(projectId).catch(() => null); + const projectBot = await projectBotService.getBotKey(projectId).catch(() => null); return (el: { ciphertext?: string; iv: string; tag: string }) => - botKey + projectBot?.botKey ? getAllNestedSecretReferences( decryptSymmetric128BitHexKeyUTF8({ ciphertext: el.ciphertext || "", iv: el.iv, tag: el.tag, - key: botKey + key: projectBot.botKey }) ) : undefined; @@ -951,7 +954,23 @@ export const secretServiceFactory = ({ expandSecretReferences, recursive }: TGetSecretsRawDTO) => { - const botKey = await projectBotService.getBotKey(projectId); + const { botKey, shouldUseSecretV2Bridge } = await projectBotService.getBotKey(projectId); + if (shouldUseSecretV2Bridge) { + const { secrets, imports } = await secretV2BridgeService.getSecrets({ + projectId, + expandSecretReferences, + actorId, + actor, + actorOrgId, + environment, + path, + recursive, + actorAuthMethod, + includeImports + }); + return { secrets, imports }; + } + if (!botKey) throw new BadRequestError({ message: "Project bot not found", name: "bot_not_found_error" }); const { secrets, imports } = await getSecrets({ @@ -1103,9 +1122,23 @@ export const secretServiceFactory = ({ version }: TGetASecretRawDTO) => { const projectId = workspaceId || (await projectDAL.findProjectBySlug(projectSlug as string, actorOrgId)).id; - - const botKey = await projectBotService.getBotKey(projectId); - if (!botKey) throw new BadRequestError({ message: "Project bot not found", name: "bot_not_found_error" }); + const { botKey, shouldUseSecretV2Bridge } = await projectBotService.getBotKey(projectId); + if (shouldUseSecretV2Bridge) { + const secret = await secretV2BridgeService.getSecretByName({ + environment, + projectId, + includeImports, + actorAuthMethod, + path, + actorOrgId, + actor, + actorId, + expandSecretReferences, + type, + secretName + }); + return secret; + } const encryptedSecret = await getSecretByName({ actorId, @@ -1121,6 +1154,8 @@ export const secretServiceFactory = ({ version }); + if (!botKey) + throw new BadRequestError({ message: "Please upgrade your project first", name: "bot_not_found_error" }); const decryptedSecret = decryptSecretRaw(encryptedSecret, botKey); if (expandSecretReferences) { @@ -1180,9 +1215,29 @@ export const secretServiceFactory = ({ secretReminderNote, secretReminderRepeatDays }: TCreateSecretRawDTO) => { - const botKey = await projectBotService.getBotKey(projectId); - if (!botKey) throw new BadRequestError({ message: "Project bot not found", name: "bot_not_found_error" }); + const { botKey, shouldUseSecretV2Bridge } = await projectBotService.getBotKey(projectId); + if (shouldUseSecretV2Bridge) { + const secret = await secretV2BridgeService.createSecret({ + secretName, + type, + actorId, + actor, + actorOrgId, + actorAuthMethod, + projectId, + environment, + secretPath, + secretComment, + secretValue, + tagIds, + secretReminderNote, + skipMultilineEncoding, + secretReminderRepeatDays + }); + return secret; + } + if (!botKey) throw new BadRequestError({ message: "Project bot not found", name: "bot_not_found_error" }); const secretKeyEncrypted = encryptSymmetric128BitHexKeyUTF8(secretName, botKey); const secretValueEncrypted = encryptSymmetric128BitHexKeyUTF8(secretValue || "", botKey); const secretCommentEncrypted = encryptSymmetric128BitHexKeyUTF8(secretComment || "", botKey); @@ -1234,7 +1289,30 @@ export const secretServiceFactory = ({ secretComment, newSecretName }: TUpdateSecretRawDTO) => { - const botKey = await projectBotService.getBotKey(projectId); + const { botKey, shouldUseSecretV2Bridge } = await projectBotService.getBotKey(projectId); + if (shouldUseSecretV2Bridge) { + const secret = await secretV2BridgeService.updateSecret({ + secretReminderRepeatDays, + skipMultilineEncoding, + secretReminderNote, + tagIds, + secretComment, + secretPath, + environment, + projectId, + actorAuthMethod, + actorOrgId, + actor, + actorId, + type, + secretName, + newSecretName, + metadata, + secretValue + }); + return secret; + } + if (!botKey) throw new BadRequestError({ message: "Project bot not found", name: "bot_not_found_error" }); const secretValueEncrypted = encryptSymmetric128BitHexKeyUTF8(secretValue || "", botKey); @@ -1283,7 +1361,21 @@ export const secretServiceFactory = ({ type, secretPath }: TDeleteSecretRawDTO) => { - const botKey = await projectBotService.getBotKey(projectId); + const { botKey, shouldUseSecretV2Bridge } = await projectBotService.getBotKey(projectId); + if (shouldUseSecretV2Bridge) { + const secret = await secretV2BridgeService.deleteSecret({ + secretName, + type, + actorId, + actor, + actorOrgId, + actorAuthMethod, + projectId, + environment, + secretPath + }); + return secret; + } if (!botKey) throw new BadRequestError({ message: "Project bot not found", name: "bot_not_found_error" }); const secret = await deleteSecret({ @@ -1323,7 +1415,20 @@ export const secretServiceFactory = ({ projectId = project.id; } - const botKey = await projectBotService.getBotKey(projectId); + const { botKey, shouldUseSecretV2Bridge } = await projectBotService.getBotKey(projectId); + if (shouldUseSecretV2Bridge) { + const secrets = await secretV2BridgeService.createManySecret({ + secretPath, + environment, + projectId, + actorAuthMethod, + actorOrgId, + actor, + actorId, + secrets: inputSecrets + }); + return secrets; + } if (!botKey) throw new BadRequestError({ message: "Project bot not found", name: "bot_not_found_error" }); const secrets = await createManySecret({ @@ -1384,7 +1489,21 @@ export const secretServiceFactory = ({ projectId = project.id; } - const botKey = await projectBotService.getBotKey(projectId); + const { botKey, shouldUseSecretV2Bridge } = await projectBotService.getBotKey(projectId); + if (shouldUseSecretV2Bridge) { + const secrets = await secretV2BridgeService.updateManySecret({ + secretPath, + environment, + projectId, + actorAuthMethod, + actorOrgId, + actor, + actorId, + secrets: inputSecrets + }); + return secrets; + } + if (!botKey) throw new BadRequestError({ message: "Project bot not found", name: "bot_not_found_error" }); const secrets = await updateManySecret({ @@ -1457,7 +1576,21 @@ export const secretServiceFactory = ({ projectId = project.id; } - const botKey = await projectBotService.getBotKey(projectId); + const { botKey, shouldUseSecretV2Bridge } = await projectBotService.getBotKey(projectId); + if (shouldUseSecretV2Bridge) { + const secrets = await secretV2BridgeService.deleteManySecret({ + secretPath, + environment, + projectId, + actorAuthMethod, + actorOrgId, + actor, + actorId, + secrets: inputSecrets + }); + return secrets; + } + if (!botKey) throw new BadRequestError({ message: "Project bot not found", name: "bot_not_found_error" }); const secrets = await deleteManySecret({ @@ -1517,7 +1650,6 @@ export const secretServiceFactory = ({ actorId }: TAttachSecretTagsDTO) => { const project = await projectDAL.findProjectBySlug(projectSlug, actorOrgId); - const { permission } = await permissionService.getProjectPermission( actor, actorId, @@ -1620,7 +1752,6 @@ export const secretServiceFactory = ({ actorId }: TAttachSecretTagsDTO) => { const project = await projectDAL.findProjectBySlug(projectSlug, actorOrgId); - const { permission } = await permissionService.getProjectPermission( actor, actorId, @@ -1735,7 +1866,17 @@ export const secretServiceFactory = ({ if (!hasRole(ProjectMembershipRole.Admin)) throw new BadRequestError({ message: "Only admins are allowed to take this action" }); - const botKey = await projectBotService.getBotKey(projectId); + const { botKey, shouldUseSecretV2Bridge } = await projectBotService.getBotKey(projectId); + if (shouldUseSecretV2Bridge) { + return secretV2BridgeService.backfillSecretReferences({ + projectId, + actor, + actorId, + actorOrgId, + actorAuthMethod + }); + } + if (!botKey) throw new BadRequestError({ message: "Please upgrade your project first", name: "bot_not_found_error" }); @@ -1779,6 +1920,21 @@ export const secretServiceFactory = ({ message: "Project not found." }); } + if (project.version === 3) { + return secretV2BridgeService.moveSecrets({ + sourceEnvironment, + sourceSecretPath, + destinationEnvironment, + destinationSecretPath, + secretIds, + projectId: project.id, + shouldOverwrite, + actor, + actorId, + actorAuthMethod, + actorOrgId + }); + } const { permission } = await permissionService.getProjectPermission( actor, @@ -1803,7 +1959,7 @@ export const secretServiceFactory = ({ subject(ProjectPermissionSub.Secrets, { environment: destinationEnvironment, secretPath: destinationSecretPath }) ); - const botKey = await projectBotService.getBotKey(project.id); + const { botKey } = await projectBotService.getBotKey(project.id); if (!botKey) { throw new BadRequestError({ message: "Project bot not found", name: "bot_not_found_error" }); }