diff --git a/backend-pg/package-lock.json b/backend-pg/package-lock.json index c0d015f74..7d959b124 100644 --- a/backend-pg/package-lock.json +++ b/backend-pg/package-lock.json @@ -19,6 +19,7 @@ "@fastify/swagger": "^8.12.0", "@fastify/swagger-ui": "^1.10.1", "@ucast/mongo2js": "^1.3.4", + "argon2": "^0.31.2", "axios": "^1.6.2", "axios-retry": "^4.0.0", "bcrypt": "^5.1.1", @@ -38,6 +39,8 @@ "pg": "^8.11.3", "picomatch": "^3.0.1", "pino": "^8.16.2", + "tweetnacl": "^1.0.3", + "tweetnacl-util": "^0.15.1", "zod": "^3.22.4", "zod-to-json-schema": "^3.22.0" }, @@ -940,6 +943,14 @@ "node": ">= 8" } }, + "node_modules/@phc/format": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@phc/format/-/format-1.0.0.tgz", + "integrity": "sha512-m7X9U6BG2+J+R1lSOdCiITLLrxm+cWlNI3HUFA92oLO77ObGNzaKdh8pMLqdZcshtkKuV84olNNXDfMc4FezBQ==", + "engines": { + "node": ">=10" + } + }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", @@ -2269,6 +2280,25 @@ "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", "dev": true }, + "node_modules/argon2": { + "version": "0.31.2", + "resolved": "https://registry.npmjs.org/argon2/-/argon2-0.31.2.tgz", + "integrity": "sha512-QSnJ8By5Mth60IEte45w9Y7v6bWcQw3YhRtJKKN8oNCxnTLDiv/AXXkDPf2srTMfxFVn3QJdVv2nhXESsUa+Yg==", + "hasInstallScript": true, + "dependencies": { + "@mapbox/node-pre-gyp": "^1.0.11", + "@phc/format": "^1.0.0", + "node-addon-api": "^7.0.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/argon2/node_modules/node-addon-api": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.0.0.tgz", + "integrity": "sha512-vgbBJTS4m5/KkE16t5Ly0WW9hz46swAstv0hYYwMtbG7AznRhNyfLRe8HZAiWIpcHzoO7HxhLuBQj9rJ/Ho0ZA==" + }, "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", @@ -8571,6 +8601,16 @@ "fsevents": "~2.3.3" } }, + "node_modules/tweetnacl": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-1.0.3.tgz", + "integrity": "sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw==" + }, + "node_modules/tweetnacl-util": { + "version": "0.15.1", + "resolved": "https://registry.npmjs.org/tweetnacl-util/-/tweetnacl-util-0.15.1.tgz", + "integrity": "sha512-RKJBIj8lySrShN4w6i/BonWp2Z/uxwC3h4y7xsRrpP59ZboCd0GpEVsOnMDYLMmKBpYhb5TgHzZXy7wTfYFBRw==" + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", diff --git a/backend-pg/package.json b/backend-pg/package.json index 4c77f294b..151c40a11 100644 --- a/backend-pg/package.json +++ b/backend-pg/package.json @@ -72,6 +72,7 @@ "@fastify/swagger": "^8.12.0", "@fastify/swagger-ui": "^1.10.1", "@ucast/mongo2js": "^1.3.4", + "argon2": "^0.31.2", "axios": "^1.6.2", "axios-retry": "^4.0.0", "bcrypt": "^5.1.1", @@ -91,6 +92,8 @@ "pg": "^8.11.3", "picomatch": "^3.0.1", "pino": "^8.16.2", + "tweetnacl": "^1.0.3", + "tweetnacl-util": "^0.15.1", "zod": "^3.22.4", "zod-to-json-schema": "^3.22.0" } diff --git a/backend-pg/scripts/create-migration.ts b/backend-pg/scripts/create-migration.ts index c3f4a0746..f4017b12b 100644 --- a/backend-pg/scripts/create-migration.ts +++ b/backend-pg/scripts/create-migration.ts @@ -3,7 +3,7 @@ import { execSync } from "child_process"; import path from "path"; import promptSync from "prompt-sync"; -const prompt = promptSync(); +const prompt = promptSync({ sigint: true }); const migrationName = prompt("Enter name for migration: "); diff --git a/backend-pg/scripts/create-seed-file.ts b/backend-pg/scripts/create-seed-file.ts index 6735eb371..25faf94c3 100644 --- a/backend-pg/scripts/create-seed-file.ts +++ b/backend-pg/scripts/create-seed-file.ts @@ -4,7 +4,7 @@ import { readdirSync } from "fs"; import path from "path"; import promptSync from "prompt-sync"; -const prompt = promptSync(); +const prompt = promptSync({ sigint: true }); const migrationName = prompt("Enter name for seedfile: "); const fileCounter = readdirSync(path.join(__dirname, "../src/db/seed")).length || 1; diff --git a/backend-pg/scripts/generate-schema-types.ts b/backend-pg/scripts/generate-schema-types.ts index 03ac4e2e4..daab0ae3a 100644 --- a/backend-pg/scripts/generate-schema-types.ts +++ b/backend-pg/scripts/generate-schema-types.ts @@ -5,7 +5,7 @@ import knex from "knex"; import { writeFileSync } from "fs"; import promptSync from "prompt-sync"; -const prompt = promptSync(); +const prompt = promptSync({ sigint: true }); dotenv.config({ path: path.join(__dirname, "../.env"), diff --git a/backend-pg/src/@types/fastify.d.ts b/backend-pg/src/@types/fastify.d.ts index 1d8c2c2c8..eca5debce 100644 --- a/backend-pg/src/@types/fastify.d.ts +++ b/backend-pg/src/@types/fastify.d.ts @@ -15,6 +15,9 @@ import { TProjectEnvServiceFactory } from "@app/services/project-env/project-env import { TProjectKeyServiceFactory } from "@app/services/project-key/project-key-service"; import { TProjectMembershipServiceFactory } from "@app/services/project-membership/project-membership-service"; import { TProjectRoleServiceFactory } from "@app/services/project-role/project-role-service"; +import { TSecretServiceFactory } from "@app/services/secret/secret-service"; +import { TSecretFolderServiceFactory } from "@app/services/secret-folder/secret-folder-service"; +import { TSecretImportServiceFactory } from "@app/services/secret-import/secret-import-service"; import { TSuperAdminServiceFactory } from "@app/services/super-admin/super-admin-service"; import { TAuthTokenServiceFactory } from "@app/services/token/token-service"; import { TUserDalFactory } from "@app/services/user/user-dal"; @@ -64,6 +67,9 @@ declare module "fastify" { projectEnv: TProjectEnvServiceFactory; projectKey: TProjectKeyServiceFactory; projectRole: TProjectRoleServiceFactory; + secret: TSecretServiceFactory; + secretImport: TSecretImportServiceFactory; + folder: TSecretFolderServiceFactory; }; // this is exclusive use for middlewares in which we need to inject data diff --git a/backend-pg/src/@types/knex.d.ts b/backend-pg/src/@types/knex.d.ts index 686441dde..afbb90709 100644 --- a/backend-pg/src/@types/knex.d.ts +++ b/backend-pg/src/@types/knex.d.ts @@ -40,6 +40,27 @@ import { TProjects, TProjectsInsert, TProjectsUpdate, + TSecretBlindIndexes, + TSecretBlindIndexesInsert, + TSecretBlindIndexesUpdate, + TSecretFolders, + TSecretFoldersInsert, + TSecretFoldersUpdate, + TSecretImports, + TSecretImportsInsert, + TSecretImportsUpdate, + TSecrets, + TSecretsInsert, + TSecretsUpdate, + TSecretTagJunction, + TSecretTagJunctionInsert, + TSecretTagJunctionUpdate, + TSecretTags, + TSecretTagsInsert, + TSecretTagsUpdate, + TSecretVersions, + TSecretVersionsInsert, + TSecretVersionsUpdate, TSuperAdmin, TSuperAdminInsert, TSuperAdminUpdate, @@ -125,5 +146,36 @@ declare module "knex/types/tables" { TProjectKeysInsert, TProjectKeysUpdate >; + [TableName.Secret]: Knex.CompositeTableType; + [TableName.SecretBlindIndex]: Knex.CompositeTableType< + TSecretBlindIndexes, + TSecretBlindIndexesInsert, + TSecretBlindIndexesUpdate + >; + [TableName.SecretVersion]: Knex.CompositeTableType< + TSecretVersions, + TSecretVersionsInsert, + TSecretVersionsUpdate + >; + [TableName.SecretFolder]: Knex.CompositeTableType< + TSecretFolders, + TSecretFoldersInsert, + TSecretFoldersUpdate + >; + [TableName.SecretTag]: Knex.CompositeTableType< + TSecretTags, + TSecretTagsInsert, + TSecretTagsUpdate + >; + [TableName.SecretImport]: Knex.CompositeTableType< + TSecretImports, + TSecretImportsInsert, + TSecretImportsUpdate + >; + [TableName.JnSecretTag]: Knex.CompositeTableType< + TSecretTagJunction, + TSecretTagJunctionInsert, + TSecretTagJunctionUpdate + >; } } diff --git a/backend-pg/src/db/migrations/20231212110939_project.ts b/backend-pg/src/db/migrations/20231212110939_project.ts index 2689fe527..ee4d32530 100644 --- a/backend-pg/src/db/migrations/20231212110939_project.ts +++ b/backend-pg/src/db/migrations/20231212110939_project.ts @@ -21,8 +21,14 @@ export async function up(knex: Knex): Promise { t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); t.string("name").notNullable(); t.string("slug").notNullable(); + t.integer("position").notNullable(); t.uuid("projectId").notNullable(); t.foreign("projectId").references("id").inTable(TableName.Project).onDelete("CASCADE"); + // this will ensure ever env has its position + t.unique(["projectId", "position"], { + indexName: "env_pos_composite_uniqe", + deferrable: "deferred" + }); t.timestamps(true, true, true); }); } @@ -34,9 +40,9 @@ export async function up(knex: Knex): Promise { t.text("nonce").notNullable(); t.uuid("receiverId").notNullable(); t.foreign("receiverId").references("id").inTable(TableName.Users).onDelete("CASCADE"); - t.uuid("senderId").notNullable(); + t.uuid("senderId"); // if sender is deleted just don't do anything to this record - t.foreign("senderId").references("id").inTable(TableName.Users).onDelete("NO ACTION"); + t.foreign("senderId").references("id").inTable(TableName.Users).onDelete("SET NULL"); t.uuid("projectId").notNullable(); t.foreign("projectId").references("id").inTable(TableName.Project).onDelete("CASCADE"); t.timestamps(true, true, true); diff --git a/backend-pg/src/db/migrations/20231218092441_secret-folder.ts b/backend-pg/src/db/migrations/20231218092441_secret-folder.ts new file mode 100644 index 000000000..bf6c28d79 --- /dev/null +++ b/backend-pg/src/db/migrations/20231218092441_secret-folder.ts @@ -0,0 +1,25 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; +import { createOnUpdateTrigger, dropOnUpdateTrigger } from "../utils"; + +export async function up(knex: Knex): Promise { + if (!(await knex.schema.hasTable(TableName.SecretFolder))) { + await knex.schema.createTable(TableName.SecretFolder, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + t.string("name").notNullable(); + t.integer("version").defaultTo(1); + t.timestamps(true, true, true); + t.uuid("envId").notNullable(); + t.foreign("envId").references("id").inTable(TableName.Environment).onDelete("CASCADE"); + t.uuid("parentId"); + t.foreign("parentId").references("id").inTable(TableName.SecretFolder).onDelete("CASCADE"); + }); + } + await createOnUpdateTrigger(knex, TableName.SecretFolder); +} + +export async function down(knex: Knex): Promise { + await knex.schema.dropTableIfExists(TableName.SecretFolder); + await dropOnUpdateTrigger(knex, TableName.SecretFolder); +} diff --git a/backend-pg/src/db/migrations/20231218092508_secret-import.ts b/backend-pg/src/db/migrations/20231218092508_secret-import.ts new file mode 100644 index 000000000..12aa80c74 --- /dev/null +++ b/backend-pg/src/db/migrations/20231218092508_secret-import.ts @@ -0,0 +1,30 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; +import { createOnUpdateTrigger, dropOnUpdateTrigger } from "../utils"; + +export async function up(knex: Knex): Promise { + if (!(await knex.schema.hasTable(TableName.SecretImport))) { + await knex.schema.createTable(TableName.SecretImport, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + t.integer("version").defaultTo(1); + t.string("importPath").notNullable(); + t.uuid("importEnv").notNullable(); + t.foreign("importEnv").references("id").inTable(TableName.Environment).onDelete("CASCADE"); + t.integer("position").notNullable(); + t.timestamps(true, true, true); + t.uuid("folderId").notNullable(); + t.foreign("folderId").references("id").inTable(TableName.SecretFolder).onDelete("CASCADE"); + t.unique(["folderId", "position"], { + indexName: "import_pos_composite_uniqe", + deferrable: "deferred" + }); + }); + } + await createOnUpdateTrigger(knex, TableName.SecretImport); +} + +export async function down(knex: Knex): Promise { + await knex.schema.dropTableIfExists(TableName.SecretImport); + await dropOnUpdateTrigger(knex, TableName.SecretImport); +} diff --git a/backend-pg/src/db/migrations/20231218092517_secret-tag.ts b/backend-pg/src/db/migrations/20231218092517_secret-tag.ts new file mode 100644 index 000000000..63cbc0daa --- /dev/null +++ b/backend-pg/src/db/migrations/20231218092517_secret-tag.ts @@ -0,0 +1,26 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; +import { createOnUpdateTrigger, dropOnUpdateTrigger } from "../utils"; + +export async function up(knex: Knex): Promise { + if (!(await knex.schema.hasTable(TableName.SecretTag))) { + await knex.schema.createTable(TableName.SecretTag, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + t.string("name").notNullable(); + t.string("slug").notNullable(); + t.string("tagColor").notNullable(); + t.timestamps(true, true, true); + t.uuid("createdBy").notNullable(); + t.foreign("createdBy").references("id").inTable(TableName.Users).onDelete("NO ACTION"); + t.uuid("projectId").notNullable(); + t.foreign("projectId").references("id").inTable(TableName.Project).onDelete("CASCADE"); + }); + } + await createOnUpdateTrigger(knex, TableName.SecretTag); +} + +export async function down(knex: Knex): Promise { + await knex.schema.dropTableIfExists(TableName.SecretTag); + await dropOnUpdateTrigger(knex, TableName.SecretTag); +} diff --git a/backend-pg/src/db/migrations/20231218103423_secret.ts b/backend-pg/src/db/migrations/20231218103423_secret.ts new file mode 100644 index 000000000..863f17646 --- /dev/null +++ b/backend-pg/src/db/migrations/20231218103423_secret.ts @@ -0,0 +1,65 @@ +import { Knex } from "knex"; + +import { SecretEncryptionAlgo, SecretKeyEncoding, SecretType, TableName } from "../schemas"; +import { createJunctionTable, createOnUpdateTrigger, dropOnUpdateTrigger } from "../utils"; + +export async function up(knex: Knex): Promise { + if (!(await knex.schema.hasTable(TableName.SecretBlindIndex))) { + await knex.schema.createTable(TableName.SecretBlindIndex, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + t.text("encryptedSaltCipherText").notNullable(); + t.text("saltIV").notNullable(); + t.text("saltTag").notNullable(); + t.string("algorithm").notNullable().defaultTo(SecretEncryptionAlgo.AES_256_GCM); + t.string("keyEncoding").notNullable().defaultTo(SecretKeyEncoding.UTF8); + t.uuid("projectId").notNullable().unique(); + t.foreign("projectId").references("id").inTable(TableName.Project).onDelete("CASCADE"); + t.timestamps(true, true, true); + }); + } + await createOnUpdateTrigger(knex, TableName.SecretBlindIndex); + + if (!(await knex.schema.hasTable(TableName.Secret))) { + await knex.schema.createTable(TableName.Secret, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + t.integer("version").defaultTo(1); + t.string("type").notNullable().defaultTo(SecretType.Shared); + // t.text("secretKeyHash").notNullable(); + // t.text("secretValueHash"); + // t.text("secretCommentHash"); + t.text("secretBlindIndex").notNullable(); + t.text("secretKeyCiphertext").notNullable(); + t.text("secretKeyIV").notNullable(); + t.text("secretKeyTag").notNullable(); + t.text("secretValueCiphertext").notNullable(); + t.text("secretValueIV").notNullable(); // symmetric encryption + t.text("secretValueTag").notNullable(); + t.text("secretCommentCiphertext"); + t.text("secretCommentIV"); + t.text("secretCommentTag"); + t.string("secretReminderNotice"); + t.integer("secretReminderRepeatDays"); + t.boolean("skipMultilineEncoding").defaultTo(false); + t.string("algorithm").notNullable().defaultTo(SecretEncryptionAlgo.AES_256_GCM); + t.string("keyEncoding").notNullable().defaultTo(SecretKeyEncoding.UTF8); + t.jsonb("metadata"); + t.uuid("userId"); + t.foreign("userId").references("id").inTable(TableName.Users).onDelete("CASCADE"); + t.uuid("folderId").notNullable(); + t.foreign("folderId").references("id").inTable(TableName.SecretFolder).onDelete("CASCADE"); + t.timestamps(true, true, true); + }); + } + await createOnUpdateTrigger(knex, TableName.Secret); + // many to many relation between tags + await createJunctionTable(knex, TableName.JnSecretTag, TableName.Secret, TableName.SecretTag); +} + +export async function down(knex: Knex): Promise { + await knex.schema.dropTableIfExists(TableName.SecretBlindIndex); + await dropOnUpdateTrigger(knex, TableName.SecretBlindIndex); + + await knex.schema.dropTableIfExists(TableName.JnSecretTag); + await knex.schema.dropTableIfExists(TableName.Secret); + await dropOnUpdateTrigger(knex, TableName.Secret); +} diff --git a/backend-pg/src/db/migrations/20231220052508_secret-version.ts b/backend-pg/src/db/migrations/20231220052508_secret-version.ts new file mode 100644 index 000000000..de079c1e9 --- /dev/null +++ b/backend-pg/src/db/migrations/20231220052508_secret-version.ts @@ -0,0 +1,54 @@ +import { Knex } from "knex"; + +import { SecretEncryptionAlgo, SecretKeyEncoding, SecretType, TableName } from "../schemas"; +import { createJunctionTable, createOnUpdateTrigger, dropOnUpdateTrigger } from "../utils"; + +export async function up(knex: Knex): Promise { + if (!(await knex.schema.hasTable(TableName.SecretVersion))) { + await knex.schema.createTable(TableName.SecretVersion, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + t.integer("version").defaultTo(1); + t.string("type").notNullable().defaultTo(SecretType.Shared); + // t.text("secretKeyHash").notNullable(); + // t.text("secretValueHash"); + // t.text("secretCommentHash"); + t.text("secretBlindIndex").notNullable(); + t.text("secretKeyCiphertext").notNullable(); + t.text("secretKeyIV").notNullable(); + t.text("secretKeyTag").notNullable(); + t.text("secretValueCiphertext").notNullable(); + t.text("secretValueIV").notNullable(); // symmetric encryption + t.text("secretValueTag").notNullable(); + t.text("secretCommentCiphertext"); + t.text("secretCommentIV"); + t.text("secretCommentTag"); + t.string("secretReminderNotice"); + t.integer("secretReminderRepeatDays"); + t.boolean("skipMultilineEncoding").defaultTo(false); + t.string("algorithm").notNullable().defaultTo(SecretEncryptionAlgo.AES_256_GCM); + t.string("keyEncoding").notNullable().defaultTo(SecretKeyEncoding.UTF8); + t.jsonb("metadata"); + t.uuid("secretId"); + t.foreign("secretId").references("id").inTable(TableName.Secret).onDelete("SET NULL"); + t.uuid("userId"); + t.foreign("userId").references("id").inTable(TableName.Users).onDelete("CASCADE"); + t.uuid("folderId").notNullable(); + t.foreign("folderId").references("id").inTable(TableName.SecretFolder).onDelete("CASCADE"); + t.timestamps(true, true, true); + }); + } + await createOnUpdateTrigger(knex, TableName.SecretVersion); + // many to many relation between tags + await createJunctionTable( + knex, + TableName.JnSecretVersionTag, + TableName.SecretVersion, + TableName.SecretTag + ); +} + +export async function down(knex: Knex): Promise { + await knex.schema.dropTableIfExists(TableName.JnSecretVersionTag); + await knex.schema.dropTableIfExists(TableName.SecretVersion); + await dropOnUpdateTrigger(knex, TableName.SecretVersion); +} diff --git a/backend-pg/src/db/schemas/index.ts b/backend-pg/src/db/schemas/index.ts index 4ae378c76..683e0aa65 100644 --- a/backend-pg/src/db/schemas/index.ts +++ b/backend-pg/src/db/schemas/index.ts @@ -12,6 +12,13 @@ export * from "./project-keys"; export * from "./project-memberships"; export * from "./project-roles"; export * from "./projects"; +export * from "./secret-blind-indexes"; +export * from "./secret-folders"; +export * from "./secret-imports"; +export * from "./secret-tag-junction"; +export * from "./secret-tags"; +export * from "./secret-versions"; +export * from "./secrets"; export * from "./super-admin"; export * from "./user-actions"; export * from "./user-encryption-keys"; diff --git a/backend-pg/src/db/schemas/models.ts b/backend-pg/src/db/schemas/models.ts index 596f7ccb7..c7a1c443f 100644 --- a/backend-pg/src/db/schemas/models.ts +++ b/backend-pg/src/db/schemas/models.ts @@ -17,7 +17,15 @@ export enum TableName { Environment = "project_environments", ProjectMembership = "project_memberships", ProjectRoles = "project_roles", - ProjectKeys = "project_keys" + ProjectKeys = "project_keys", + Secret = "secrets", + SecretBlindIndex = "secret_blind_indexes", + SecretVersion = "secret_versions", + SecretFolder = "secret_folders", + SecretImport = "secret_imports", + SecretTag = "secret_tags", + JnSecretTag = "secret_tag_junction", + JnSecretVersionTag = "secret_version_tag_junction" } export type TImmutableDBKeys = "id" | "createdAt" | "updatedAt"; @@ -58,3 +66,8 @@ export enum SecretKeyEncoding { BASE64 = "base64", HEX = "hex" } + +export enum SecretType { + Shared = "shared", + Personal = "personal" +} diff --git a/backend-pg/src/db/schemas/project-environments.ts b/backend-pg/src/db/schemas/project-environments.ts index 1521393b8..53f3f9713 100644 --- a/backend-pg/src/db/schemas/project-environments.ts +++ b/backend-pg/src/db/schemas/project-environments.ts @@ -11,6 +11,7 @@ export const ProjectEnvironmentsSchema = z.object({ id: z.string().uuid(), name: z.string(), slug: z.string(), + position: z.number(), projectId: z.string().uuid(), createdAt: z.date(), updatedAt: z.date(), diff --git a/backend-pg/src/db/schemas/secret-blind-indexes.ts b/backend-pg/src/db/schemas/secret-blind-indexes.ts new file mode 100644 index 000000000..695566d08 --- /dev/null +++ b/backend-pg/src/db/schemas/secret-blind-indexes.ts @@ -0,0 +1,24 @@ +// Code generated by automation script, DO NOT EDIT. +// Automated by pulling database and generating zod schema +// To update. Just run npm run generate:schema +// Written by akhilmhdh. + +import { z } from "zod"; + +import { TImmutableDBKeys } from "./models"; + +export const SecretBlindIndexesSchema = z.object({ + id: z.string().uuid(), + encryptedSaltCipherText: z.string(), + saltIV: z.string(), + saltTag: z.string(), + algorithm: z.string().default('aes-256-gcm'), + keyEncoding: z.string().default('utf8'), + projectId: z.string().uuid(), + createdAt: z.date(), + updatedAt: z.date(), +}); + +export type TSecretBlindIndexes = z.infer; +export type TSecretBlindIndexesInsert = Omit; +export type TSecretBlindIndexesUpdate = Partial>; diff --git a/backend-pg/src/db/schemas/secret-folders.ts b/backend-pg/src/db/schemas/secret-folders.ts new file mode 100644 index 000000000..aa437c753 --- /dev/null +++ b/backend-pg/src/db/schemas/secret-folders.ts @@ -0,0 +1,22 @@ +// Code generated by automation script, DO NOT EDIT. +// Automated by pulling database and generating zod schema +// To update. Just run npm run generate:schema +// Written by akhilmhdh. + +import { z } from "zod"; + +import { TImmutableDBKeys } from "./models"; + +export const SecretFoldersSchema = z.object({ + id: z.string().uuid(), + name: z.string(), + version: z.number().default(1).nullable().optional(), + createdAt: z.date(), + updatedAt: z.date(), + envId: z.string().uuid(), + parentId: z.string().uuid().nullable().optional(), +}); + +export type TSecretFolders = z.infer; +export type TSecretFoldersInsert = Omit; +export type TSecretFoldersUpdate = Partial>; diff --git a/backend-pg/src/db/schemas/secret-imports.ts b/backend-pg/src/db/schemas/secret-imports.ts new file mode 100644 index 000000000..109d41ec2 --- /dev/null +++ b/backend-pg/src/db/schemas/secret-imports.ts @@ -0,0 +1,23 @@ +// Code generated by automation script, DO NOT EDIT. +// Automated by pulling database and generating zod schema +// To update. Just run npm run generate:schema +// Written by akhilmhdh. + +import { z } from "zod"; + +import { TImmutableDBKeys } from "./models"; + +export const SecretImportsSchema = z.object({ + id: z.string().uuid(), + version: z.number().default(1).nullable().optional(), + importPath: z.string(), + importEnv: z.string().uuid(), + position: z.number(), + createdAt: z.date(), + updatedAt: z.date(), + folderId: z.string().uuid(), +}); + +export type TSecretImports = z.infer; +export type TSecretImportsInsert = Omit; +export type TSecretImportsUpdate = Partial>; diff --git a/backend-pg/src/db/schemas/secret-tag-junction.ts b/backend-pg/src/db/schemas/secret-tag-junction.ts new file mode 100644 index 000000000..467ef7b15 --- /dev/null +++ b/backend-pg/src/db/schemas/secret-tag-junction.ts @@ -0,0 +1,18 @@ +// Code generated by automation script, DO NOT EDIT. +// Automated by pulling database and generating zod schema +// To update. Just run npm run generate:schema +// Written by akhilmhdh. + +import { z } from "zod"; + +import { TImmutableDBKeys } from "./models"; + +export const SecretTagJunctionSchema = z.object({ + id: z.string().uuid(), + secretsId: z.string().uuid(), + secret_tagsId: z.string().uuid(), +}); + +export type TSecretTagJunction = z.infer; +export type TSecretTagJunctionInsert = Omit; +export type TSecretTagJunctionUpdate = Partial>; diff --git a/backend-pg/src/db/schemas/secret-tags.ts b/backend-pg/src/db/schemas/secret-tags.ts new file mode 100644 index 000000000..1da8a715a --- /dev/null +++ b/backend-pg/src/db/schemas/secret-tags.ts @@ -0,0 +1,23 @@ +// Code generated by automation script, DO NOT EDIT. +// Automated by pulling database and generating zod schema +// To update. Just run npm run generate:schema +// Written by akhilmhdh. + +import { z } from "zod"; + +import { TImmutableDBKeys } from "./models"; + +export const SecretTagsSchema = z.object({ + id: z.string().uuid(), + name: z.string(), + slug: z.string(), + tagColor: z.string(), + createdAt: z.date(), + updatedAt: z.date(), + createdBy: z.string().uuid(), + projectId: z.string().uuid(), +}); + +export type TSecretTags = z.infer; +export type TSecretTagsInsert = Omit; +export type TSecretTagsUpdate = Partial>; diff --git a/backend-pg/src/db/schemas/secret-versions.ts b/backend-pg/src/db/schemas/secret-versions.ts new file mode 100644 index 000000000..db9cdcb6f --- /dev/null +++ b/backend-pg/src/db/schemas/secret-versions.ts @@ -0,0 +1,39 @@ +// Code generated by automation script, DO NOT EDIT. +// Automated by pulling database and generating zod schema +// To update. Just run npm run generate:schema +// Written by akhilmhdh. + +import { z } from "zod"; + +import { TImmutableDBKeys } from "./models"; + +export const SecretVersionsSchema = z.object({ + id: z.string().uuid(), + version: z.number().default(1).nullable().optional(), + type: z.string().default('shared'), + secretBlindIndex: z.string(), + secretKeyCiphertext: z.string(), + secretKeyIV: z.string(), + secretKeyTag: z.string(), + secretValueCiphertext: z.string(), + secretValueIV: z.string(), + secretValueTag: z.string(), + secretCommentCiphertext: z.string().nullable().optional(), + secretCommentIV: z.string().nullable().optional(), + secretCommentTag: z.string().nullable().optional(), + secretReminderNotice: z.string().nullable().optional(), + secretReminderRepeatDays: z.number().nullable().optional(), + skipMultilineEncoding: z.boolean().default(false).nullable().optional(), + algorithm: z.string().default('aes-256-gcm'), + keyEncoding: z.string().default('utf8'), + metadata: z.unknown().nullable().optional(), + secretId: z.string().uuid(), + userId: z.string().uuid().nullable().optional(), + folderId: z.string().uuid(), + createdAt: z.date(), + updatedAt: z.date(), +}); + +export type TSecretVersions = z.infer; +export type TSecretVersionsInsert = Omit; +export type TSecretVersionsUpdate = Partial>; diff --git a/backend-pg/src/db/schemas/secrets.ts b/backend-pg/src/db/schemas/secrets.ts new file mode 100644 index 000000000..73d72fcfa --- /dev/null +++ b/backend-pg/src/db/schemas/secrets.ts @@ -0,0 +1,38 @@ +// Code generated by automation script, DO NOT EDIT. +// Automated by pulling database and generating zod schema +// To update. Just run npm run generate:schema +// Written by akhilmhdh. + +import { z } from "zod"; + +import { TImmutableDBKeys } from "./models"; + +export const SecretsSchema = z.object({ + id: z.string().uuid(), + version: z.number().default(1).nullable().optional(), + type: z.string().default('shared'), + secretBlindIndex: z.string(), + secretKeyCiphertext: z.string(), + secretKeyIV: z.string(), + secretKeyTag: z.string(), + secretValueCiphertext: z.string(), + secretValueIV: z.string(), + secretValueTag: z.string(), + secretCommentCiphertext: z.string().nullable().optional(), + secretCommentIV: z.string().nullable().optional(), + secretCommentTag: z.string().nullable().optional(), + secretReminderNotice: z.string().nullable().optional(), + secretReminderRepeatDays: z.number().nullable().optional(), + skipMultilineEncoding: z.boolean().default(false).nullable().optional(), + algorithm: z.string().default('aes-256-gcm'), + keyEncoding: z.string().default('utf8'), + metadata: z.unknown().nullable().optional(), + userId: z.string().uuid().nullable().optional(), + folderId: z.string().uuid(), + createdAt: z.date(), + updatedAt: z.date(), +}); + +export type TSecrets = z.infer; +export type TSecretsInsert = Omit; +export type TSecretsUpdate = Partial>; diff --git a/backend-pg/src/db/utils.ts b/backend-pg/src/db/utils.ts index fdcb0fb19..f2ee0ca30 100644 --- a/backend-pg/src/db/utils.ts +++ b/backend-pg/src/db/utils.ts @@ -1,15 +1,17 @@ import { Knex } from "knex"; +import { TableName } from "./schemas"; + export const createJunctionTable = ( knex: Knex, - tableName: string, - table1Name: string, - table2Name: string + tableName: TableName, + table1Name: TableName, + table2Name: TableName ) => knex.schema.createTable(tableName, (table) => { - table.increments(); // Primary key - table.integer(`${table1Name}Id`).unsigned().notNullable(); // Foreign key for table1 - table.integer(`${table2Name}Id`).unsigned().notNullable(); // Foreign key for table2 + table.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + table.uuid(`${table1Name}Id`).unsigned().notNullable(); // Foreign key for table1 + table.uuid(`${table2Name}Id`).unsigned().notNullable(); // Foreign key for table2 table.foreign(`${table1Name}Id`).references("id").inTable(table2Name); table.foreign(`${table2Name}Id`).references("id").inTable(table1Name); }); diff --git a/backend-pg/src/lib/crypto/encryption.ts b/backend-pg/src/lib/crypto/encryption.ts new file mode 100644 index 000000000..9e0edcba8 --- /dev/null +++ b/backend-pg/src/lib/crypto/encryption.ts @@ -0,0 +1,184 @@ +import crypto from "node:crypto"; + +import * as argon2 from "argon2"; +import nacl from "tweetnacl"; +import naclUtils from "tweetnacl-util"; + +import { SecretEncryptionAlgo, SecretKeyEncoding } from "@app/db/schemas"; + +export type TDecryptSymmetricInput = { + ciphertext: string; + iv: string; + tag: string; + key: string; +}; +export const IV_BYTES_SIZE = 12; +export const BLOCK_SIZE_BYTES_16 = 16; + +export const decryptSymmetric = ({ ciphertext, iv, tag, key }: TDecryptSymmetricInput): string => { + const secretKey = crypto.createSecretKey(key, "base64"); + + const decipher = crypto.createDecipheriv( + SecretEncryptionAlgo.AES_256_GCM, + secretKey, + Buffer.from(iv, "base64") + ); + decipher.setAuthTag(Buffer.from(tag, "base64")); + let cleartext = decipher.update(ciphertext, "base64", "utf8"); + cleartext += decipher.final("utf8"); + + return cleartext; +}; + +export const encryptSymmetric = (plaintext: string, key: string) => { + const iv = crypto.randomBytes(IV_BYTES_SIZE); + + const secretKey = crypto.createSecretKey(key, "base64"); + const cipher = crypto.createCipheriv(SecretEncryptionAlgo.AES_256_GCM, secretKey, iv); + + let ciphertext = cipher.update(plaintext, "utf8", "base64"); + ciphertext += cipher.final("base64"); + + return { + ciphertext, + iv: iv.toString("base64"), + tag: cipher.getAuthTag().toString("base64") + }; +}; + +export const encryptSymmetric128BitHexKeyUTF8 = (plaintext: string, key: string) => { + const iv = crypto.randomBytes(BLOCK_SIZE_BYTES_16); + const cipher = crypto.createCipheriv(SecretEncryptionAlgo.AES_256_GCM, key, iv); + + let ciphertext = cipher.update(plaintext, "utf8", "base64"); + ciphertext += cipher.final("base64"); + + return { + ciphertext, + iv: iv.toString("base64"), + tag: cipher.getAuthTag().toString("base64") + }; +}; + +export const decryptSymmetric128BitHexKeyUTF8 = ({ + ciphertext, + iv, + tag, + key +}: TDecryptSymmetricInput): string => { + const decipher = crypto.createDecipheriv( + SecretEncryptionAlgo.AES_256_GCM, + key, + Buffer.from(iv, "base64") + ); + + decipher.setAuthTag(Buffer.from(tag, "base64")); + + let cleartext = decipher.update(ciphertext, "base64", "utf8"); + cleartext += decipher.final("utf8"); + + return cleartext; +}; + +export const encryptAsymmetric = (plaintext: string, publicKey: string, privateKey: string) => { + const nonce = nacl.randomBytes(24); + const ciphertext = nacl.box( + naclUtils.decodeUTF8(plaintext), + nonce, + naclUtils.decodeBase64(publicKey), + naclUtils.decodeBase64(privateKey) + ); + + return { + ciphertext: naclUtils.encodeBase64(ciphertext), + nonce: naclUtils.encodeBase64(nonce) + }; +}; + +export type TDecryptAsymmetricInput = { + ciphertext: string; + nonce: string; + publicKey: string; + privateKey: string; +}; + +export const decryptAsymmetric = ({ + ciphertext, + nonce, + publicKey, + privateKey +}: TDecryptAsymmetricInput) => { + const plaintext: Uint8Array | null = nacl.box.open( + naclUtils.decodeBase64(ciphertext), + naclUtils.decodeBase64(nonce), + naclUtils.decodeBase64(publicKey), + naclUtils.decodeBase64(privateKey) + ); + + if (plaintext == null) throw Error("Invalid ciphertext or keys"); + + return naclUtils.encodeUTF8(plaintext); +}; + +export type TGenSecretBlindIndex = { + secretName: string; + keyEncoding: SecretKeyEncoding; + rootEncryptionKey?: string; + encryptionKey?: string; + iv: string; + tag: string; + ciphertext: string; +}; + +export const buildSecretBlindIndexFromName = async ({ + secretName, + ciphertext, + keyEncoding, + iv, + tag, + encryptionKey, + rootEncryptionKey +}: TGenSecretBlindIndex) => { + if (!encryptionKey && !rootEncryptionKey) throw new Error("Missing secret blind index key"); + let salt = ""; + if (rootEncryptionKey && keyEncoding === SecretKeyEncoding.BASE64) { + salt = decryptSymmetric({ iv, ciphertext, key: rootEncryptionKey, tag }); + } else if (encryptionKey && keyEncoding === SecretKeyEncoding.UTF8) { + salt = decryptSymmetric128BitHexKeyUTF8({ iv, ciphertext, key: encryptionKey, tag }); + } + if (!salt) throw new Error("Missing secret blind index key"); + + const secretBlindIndex = await argon2.hash(secretName, { + type: argon2.argon2id, + salt: Buffer.from(salt, "base64"), + saltLength: 16, // default 16 bytes + memoryCost: 65536, // default pool of 64 MiB per thread. + hashLength: 32, + parallelism: 1, + raw: true + }); + + return secretBlindIndex.toString("base64"); +}; + +export const createSecretBlindIndex = (rootEncryptionKey?: string, encryptionKey?: string) => { + if (!encryptionKey && !rootEncryptionKey) throw new Error("Atleast one encryption key needed"); + const salt = crypto.randomBytes(16).toString("base64"); + if (rootEncryptionKey) { + const data = encryptSymmetric(salt, rootEncryptionKey); + return { + ...data, + algorithm: SecretEncryptionAlgo.AES_256_GCM, + keyEncoding: SecretKeyEncoding.BASE64 + }; + } + if (encryptionKey) { + const data = encryptSymmetric128BitHexKeyUTF8(salt, encryptionKey); + return { + ...data, + algorithm: SecretEncryptionAlgo.AES_256_GCM, + keyEncoding: SecretKeyEncoding.UTF8 + }; + } + throw new Error("Failed to generate blind index due to encryption key missing"); +}; diff --git a/backend-pg/src/lib/crypto/index.ts b/backend-pg/src/lib/crypto/index.ts index ae317cfa6..7538b8167 100644 --- a/backend-pg/src/lib/crypto/index.ts +++ b/backend-pg/src/lib/crypto/index.ts @@ -1 +1,11 @@ +export { + buildSecretBlindIndexFromName, + createSecretBlindIndex, + decryptAsymmetric, + decryptSymmetric, + decryptSymmetric128BitHexKeyUTF8, + encryptAsymmetric, + encryptSymmetric, + encryptSymmetric128BitHexKeyUTF8 +} from "./encryption"; export { generateSrpServerKey, srpCheckClientProof } from "./srp"; diff --git a/backend-pg/src/lib/errors/index.ts b/backend-pg/src/lib/errors/index.ts index b3a1e689d..78726d9e9 100644 --- a/backend-pg/src/lib/errors/index.ts +++ b/backend-pg/src/lib/errors/index.ts @@ -28,9 +28,9 @@ export class BadRequestError extends Error { error: unknown; - constructor({ name, error, message }: { message?: string; name: string; error?: unknown }) { + constructor({ name, error, message }: { message?: string; name?: string; error?: unknown }) { super(message ?? "The request is invalid"); - this.name = name; + this.name = name || ""; this.error = error; } } diff --git a/backend-pg/src/lib/knex/index.ts b/backend-pg/src/lib/knex/index.ts index d6b80ecca..5dfbea89e 100644 --- a/backend-pg/src/lib/knex/index.ts +++ b/backend-pg/src/lib/knex/index.ts @@ -1,8 +1,6 @@ import { Knex } from "knex"; import { Tables } from "knex/types/tables"; -import { TableName } from "@app/db/schemas"; - import { DatabaseError } from "../errors"; export * from "./join"; @@ -19,7 +17,7 @@ export const withTransaction = (db: Knex, dal: K) => ({ // What is ormify // It is to inject typical operations like find, findOne, update, delete, create // This will avoid writing most common ones each time -export const ormify = ( +export const ormify = ( db: Knex, tableName: Tname, dal?: DbOps diff --git a/backend-pg/src/lib/knex/join.ts b/backend-pg/src/lib/knex/join.ts index d66bb25fa..cd2f1e3a2 100644 --- a/backend-pg/src/lib/knex/join.ts +++ b/backend-pg/src/lib/knex/join.ts @@ -25,7 +25,6 @@ export const mergeOneToManyRelation = < prevPkId = pk; prevPkIndex += 1; } - console.log(prevPkIndex, prevPkId); groupedRecord[prevPkIndex][childKey].push(childMapper(row)); } return groupedRecord; diff --git a/backend-pg/src/server/routes/index.ts b/backend-pg/src/server/routes/index.ts index 395391f71..d786d7c10 100644 --- a/backend-pg/src/server/routes/index.ts +++ b/backend-pg/src/server/routes/index.ts @@ -28,6 +28,14 @@ import { projectMembershipDalFactory } from "@app/services/project-membership/pr import { projectMembershipServiceFactory } from "@app/services/project-membership/project-membership-service"; import { projectRoleDalFactory } from "@app/services/project-role/project-role-dal"; import { projectRoleServiceFactory } from "@app/services/project-role/project-role-service"; +import { secretBlindIndexDalFactory } from "@app/services/secret/secret-blind-index-dal"; +import { secretDalFactory } from "@app/services/secret/secret-dal"; +import { secretServiceFactory } from "@app/services/secret/secret-service"; +import { secretVersionDalFactory } from "@app/services/secret/secret-version-dal"; +import { secretFolderDalFactory } from "@app/services/secret-folder/secret-folder-dal"; +import { secretFolderServiceFactory } from "@app/services/secret-folder/secret-folder-service"; +import { secretImportDalFactory } from "@app/services/secret-import/secret-import-dal"; +import { secretImportServiceFactory } from "@app/services/secret-import/secret-import-service"; import { TSmtpService } from "@app/services/smtp/smtp-service"; import { superAdminDalFactory } from "@app/services/super-admin/super-admin-dal"; import { superAdminServiceFactory } from "@app/services/super-admin/super-admin-service"; @@ -35,10 +43,10 @@ import { userDalFactory } from "@app/services/user/user-dal"; import { userServiceFactory } from "@app/services/user/user-service"; import { injectIdentity } from "../plugins/auth/inject-identity"; +import { injectPermission } from "../plugins/auth/inject-permission"; import { registerV1Routes } from "./v1"; import { registerV2Routes } from "./v2"; import { registerV3Routes } from "./v3"; -import { injectPermission } from "../plugins/auth/inject-permission"; export const registerRoutes = async ( server: FastifyZodProvider, @@ -60,6 +68,12 @@ export const registerRoutes = async ( const projectEnvDal = projectEnvDalFactory(db); const projectKeyDal = projectKeyDalFactory(db); + const secretDal = secretDalFactory(db); + const folderDal = secretFolderDalFactory(db); + const secretImportDal = secretImportDalFactory(db); + const secretVersionDal = secretVersionDalFactory(db); + const secretBlindIndexDal = secretBlindIndexDalFactory(db); + // ee db layer ops const permissionDal = permissionDalFactory(db); @@ -104,8 +118,10 @@ export const registerRoutes = async ( const projectService = projectServiceFactory({ permissionService, projectDal, + secretBlindIndexDal, projectEnvDal, - projectMembershipDal + projectMembershipDal, + folderDal }); const projectMembershipService = projectMembershipServiceFactory({ projectMembershipDal, @@ -125,6 +141,25 @@ export const registerRoutes = async ( }); const projectRoleService = projectRoleServiceFactory({ permissionService, projectRoleDal }); + const secretService = secretServiceFactory({ + folderDal, + secretVersionDal, + secretBlindIndexDal, + permissionService, + secretDal + }); + const folderService = secretFolderServiceFactory({ + permissionService, + folderDal, + projectEnvDal + }); + const secretImportService = secretImportServiceFactory({ + projectEnvDal, + folderDal, + permissionService, + secretImportDal + }); + await superAdminService.initServerCfg(); // inject all services server.decorate("services", { @@ -142,7 +177,10 @@ export const registerRoutes = async ( projectMembership: projectMembershipService, projectKey: projectKeyService, projectEnv: projectEnvService, - projectRole: projectRoleService + projectRole: projectRoleService, + secret: secretService, + folder: folderService, + secretImport: secretImportService }); server.decorate("store", { @@ -169,6 +207,7 @@ export const registerRoutes = async ( }, handler: () => { const appCfg = getConfig(); + return { date: new Date(), message: "Ok" as const, diff --git a/backend-pg/src/server/routes/v1/index.ts b/backend-pg/src/server/routes/v1/index.ts index 5aadc47a7..a9f4d93db 100644 --- a/backend-pg/src/server/routes/v1/index.ts +++ b/backend-pg/src/server/routes/v1/index.ts @@ -7,6 +7,8 @@ import { registerProjectEnvRouter } from "./project-env-router"; import { registerProjectKeyRouter } from "./project-key-router"; import { registerProjectMembershipRouter } from "./project-membership-router"; import { registerProjectRouter } from "./project-router"; +import { registerSecretFolderRouter } from "./secret-folder-router"; +import { registerSecretImportRouter } from "./secret-import-router"; import { registerSsoRouter } from "./sso-router"; import { registerUserActionRouter } from "./user-action-router"; import { registerUserRouter } from "./user-router"; @@ -20,6 +22,8 @@ export const registerV1Routes = async (server: FastifyZodProvider) => { await server.register(registerUserRouter, { prefix: "/user" }); await server.register(registerInviteOrgRouter, { prefix: "/invite-org" }); await server.register(registerUserActionRouter, { prefix: "/user-action" }); + await server.register(registerSecretImportRouter, { prefix: "/secret-imports" }); + await server.register(registerSecretFolderRouter, { prefix: "/folders" }); await server.register( async (projectServer) => { diff --git a/backend-pg/src/server/routes/v1/secret-folder-router.ts b/backend-pg/src/server/routes/v1/secret-folder-router.ts new file mode 100644 index 000000000..bc3b64963 --- /dev/null +++ b/backend-pg/src/server/routes/v1/secret-folder-router.ts @@ -0,0 +1,121 @@ +import { z } from "zod"; + +import { SecretFoldersSchema } from "@app/db/schemas"; +import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; +import { AuthMode } from "@app/services/auth/auth-type"; + +export const registerSecretFolderRouter = async (server: FastifyZodProvider) => { + server.route({ + url: "/", + method: "POST", + schema: { + body: z.object({ + projectId: z.string().trim(), + environment: z.string().trim(), + name: z.string().trim(), + path: z.string().trim().default("/") + }), + response: { + 200: z.object({ + folder: SecretFoldersSchema + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.API_KEY]), + handler: async (req) => { + const folder = await server.services.folder.createFolder({ + actorId: req.permission.id, + actor: req.permission.type, + ...req.body + }); + return { folder }; + } + }); + + server.route({ + url: "/:folderId", + method: "PATCH", + schema: { + params: z.object({ + folderId: z.string() + }), + body: z.object({ + projectId: z.string().trim(), + environment: z.string().trim(), + name: z.string().trim(), + path: z.string().trim().default("/") + }), + response: { + 200: z.object({ + folder: SecretFoldersSchema + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.API_KEY]), + handler: async (req) => { + const folder = await server.services.folder.updateFolder({ + actorId: req.permission.id, + actor: req.permission.type, + ...req.body, + id: req.params.folderId + }); + return { folder }; + } + }); + + server.route({ + url: "/:folderId", + method: "DELETE", + schema: { + params: z.object({ + folderId: z.string() + }), + body: z.object({ + projectId: z.string().trim(), + environment: z.string().trim(), + path: z.string().trim().default("/") + }), + response: { + 200: z.object({ + folder: SecretFoldersSchema + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.API_KEY]), + handler: async (req) => { + const folder = await server.services.folder.deleteFolder({ + actorId: req.permission.id, + actor: req.permission.type, + ...req.body, + id: req.params.folderId + }); + return { folder }; + } + }); + + server.route({ + url: "/", + method: "GET", + schema: { + querystring: z.object({ + projectId: z.string().trim(), + environment: z.string().trim(), + path: z.string().trim().default("/") + }), + response: { + 200: z.object({ + folders: SecretFoldersSchema.array() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.API_KEY]), + handler: async (req) => { + const folders = await server.services.folder.getFolders({ + actorId: req.permission.id, + actor: req.permission.type, + ...req.query + }); + return { folders }; + } + }); +}; diff --git a/backend-pg/src/server/routes/v1/secret-import-router.ts b/backend-pg/src/server/routes/v1/secret-import-router.ts new file mode 100644 index 000000000..e2fe1d323 --- /dev/null +++ b/backend-pg/src/server/routes/v1/secret-import-router.ts @@ -0,0 +1,152 @@ +import { z } from "zod"; + +import { SecretImportsSchema } from "@app/db/schemas"; +import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; +import { AuthMode } from "@app/services/auth/auth-type"; + +export const registerSecretImportRouter = async (server: FastifyZodProvider) => { + server.route({ + url: "/", + method: "POST", + schema: { + body: z.object({ + projectId: z.string().trim(), + environment: z.string().trim(), + path: z.string().trim().default("/"), + import: z.object({ + environment: z.string().trim(), + path: z.string().trim() + }) + }), + response: { + 200: z.object({ + message: z.string(), + secretImport: SecretImportsSchema.omit({ importEnv: true }).merge( + z.object({ + importEnv: z.object({ name: z.string(), slug: z.string(), id: z.string() }) + }) + ) + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.API_KEY]), + handler: async (req) => { + const secretImport = await server.services.secretImport.createImport({ + actorId: req.permission.id, + actor: req.permission.type, + ...req.body, + data: req.body.import + }); + return { message: "Successfully created secret import", secretImport }; + } + }); + + server.route({ + url: "/:secretImportId", + method: "PATCH", + schema: { + params: z.object({ + secretImportId: z.string().trim() + }), + body: z.object({ + projectId: z.string().trim(), + environment: z.string().trim(), + path: z.string().trim().default("/"), + import: z.object({ + environment: z.string().trim().optional(), + path: z.string().trim().optional(), + position: z.number().optional() + }) + }), + response: { + 200: z.object({ + message: z.string(), + secretImport: SecretImportsSchema.omit({ importEnv: true }).merge( + z.object({ + importEnv: z.object({ name: z.string(), slug: z.string(), id: z.string() }) + }) + ) + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.API_KEY]), + handler: async (req) => { + const secretImport = await server.services.secretImport.updateImport({ + actorId: req.permission.id, + actor: req.permission.type, + id: req.params.secretImportId, + ...req.body, + data: req.body.import + }); + return { message: "Successfully updated secret import", secretImport }; + } + }); + + server.route({ + url: "/:secretImportId", + method: "DELETE", + schema: { + params: z.object({ + secretImportId: z.string().trim() + }), + body: z.object({ + projectId: z.string().trim(), + environment: z.string().trim(), + path: z.string().trim().default("/") + }), + response: { + 200: z.object({ + message: z.string(), + secretImport: SecretImportsSchema.omit({ importEnv: true }).merge( + z.object({ + importEnv: z.object({ name: z.string(), slug: z.string(), id: z.string() }) + }) + ) + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.API_KEY]), + handler: async (req) => { + const secretImport = await server.services.secretImport.deleteImport({ + actorId: req.permission.id, + actor: req.permission.type, + id: req.params.secretImportId, + ...req.body + }); + return { message: "Successfully deleted secret import", secretImport }; + } + }); + + server.route({ + url: "/", + method: "GET", + schema: { + querystring: z.object({ + projectId: z.string().trim(), + environment: z.string().trim(), + path: z.string().trim().default("/") + }), + response: { + 200: z.object({ + message: z.string(), + secretImports: SecretImportsSchema.omit({ importEnv: true }) + .merge( + z.object({ + importEnv: z.object({ name: z.string(), slug: z.string(), id: z.string() }) + }) + ) + .array() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.API_KEY]), + handler: async (req) => { + const secretImports = await server.services.secretImport.getImports({ + actorId: req.permission.id, + actor: req.permission.type, + ...req.query + }); + return { message: "Successfully fetched secret imports", secretImports }; + } + }); +}; diff --git a/backend-pg/src/server/routes/v2/project-router.ts b/backend-pg/src/server/routes/v2/project-router.ts index 76b3fbc3a..53becc6f2 100644 --- a/backend-pg/src/server/routes/v2/project-router.ts +++ b/backend-pg/src/server/routes/v2/project-router.ts @@ -1,7 +1,8 @@ +import { z } from "zod"; + import { ProjectKeysSchema } from "@app/db/schemas"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AuthMode } from "@app/services/auth/auth-type"; -import { z } from "zod"; export const registerProjectRouter = async (server: FastifyZodProvider) => { server.route({ diff --git a/backend-pg/src/server/routes/v3/index.ts b/backend-pg/src/server/routes/v3/index.ts index f9bd180c0..58591a555 100644 --- a/backend-pg/src/server/routes/v3/index.ts +++ b/backend-pg/src/server/routes/v3/index.ts @@ -1,4 +1,5 @@ import { registerLoginRouter } from "./login-router"; +import { registerSecretRouter } from "./secret-router"; import { registerSignupRouter } from "./signup-router"; import { registerUserRouter } from "./user-router"; @@ -6,4 +7,5 @@ export const registerV3Routes = async (server: FastifyZodProvider) => { await server.register(registerSignupRouter, { prefix: "/signup" }); await server.register(registerLoginRouter, { prefix: "/auth" }); await server.register(registerUserRouter, { prefix: "/users" }); + await server.register(registerSecretRouter, { prefix: "/secrets" }); }; diff --git a/backend-pg/src/server/routes/v3/secret-router.ts b/backend-pg/src/server/routes/v3/secret-router.ts new file mode 100644 index 000000000..033617238 --- /dev/null +++ b/backend-pg/src/server/routes/v3/secret-router.ts @@ -0,0 +1,372 @@ +import { z } from "zod"; + +import { SecretsSchema,SecretType } from "@app/db/schemas"; +import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; +import { AuthMode } from "@app/services/auth/auth-type"; + +export const registerSecretRouter = async (server: FastifyZodProvider) => { + server.route({ + url: "/", + method: "GET", + schema: { + querystring: z.object({ + workspaceId: z.string().trim(), + environment: z.string().trim(), + secretPath: z.string().trim().default("/"), + include_imports: z + .enum(["true", "false"]) + .default("false") + .transform((value) => value === "true") + }), + response: { + 200: z.object({ + secrets: SecretsSchema.omit({ secretBlindIndex: true }).array() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.API_KEY]), + handler: async (req) => { + const secrets = await server.services.secret.getSecrets({ + actorId: req.permission.id, + actor: req.permission.type, + environment: req.query.environment, + projectId: req.query.workspaceId, + path: req.query.secretPath + }); + + return { secrets }; + } + }); + + server.route({ + url: "/:secretName", + method: "GET", + schema: { + params: z.object({ + secretName: z.string().trim() + }), + querystring: z.object({ + workspaceId: z.string().trim(), + environment: z.string().trim(), + secretPath: z.string().trim().default("/"), + type: z.nativeEnum(SecretType).default(SecretType.Shared), + include_imports: z + .enum(["true", "false"]) + .default("false") + .transform((value) => value === "true") + }), + response: { + 200: z.object({ + secret: SecretsSchema.omit({ secretBlindIndex: true }) + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.API_KEY]), + handler: async (req) => { + const secret = await server.services.secret.getASecret({ + actorId: req.permission.id, + actor: req.permission.type, + environment: req.query.environment, + projectId: req.query.workspaceId, + path: req.query.secretPath, + secretName: req.params.secretName, + type: req.query.type + }); + + return { secret }; + } + }); + + server.route({ + url: "/:secretName", + method: "POST", + schema: { + body: z.object({ + workspaceId: z.string().trim(), + environment: z.string().trim(), + type: z.nativeEnum(SecretType).default(SecretType.Shared), + secretPath: z.string().trim().default("/"), + secretKeyCiphertext: z.string().trim(), + secretKeyIV: z.string().trim(), + secretKeyTag: z.string().trim(), + secretValueCiphertext: z.string().trim(), + secretValueIV: z.string().trim(), + secretValueTag: z.string().trim(), + secretCommentCiphertext: z.string().trim().optional(), + secretCommentIV: z.string().trim().optional(), + secretCommentTag: z.string().trim().optional(), + metadata: z.record(z.string()).optional(), + skipMultilineEncoding: z.boolean().optional() + }), + params: z.object({ + secretName: z.string().trim() + }), + response: { + 200: z.object({ + secret: SecretsSchema.omit({ secretBlindIndex: true }) + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.API_KEY]), + handler: async (req) => { + const secret = await server.services.secret.createSecret({ + actorId: req.permission.id, + actor: req.permission.type, + path: req.body.secretPath, + type: req.body.type, + environment: req.body.environment, + secretName: req.params.secretName, + projectId: req.body.workspaceId, + secretKeyIV: req.body.secretKeyIV, + secretKeyTag: req.body.secretKeyTag, + secretKeyCiphertext: req.body.secretKeyCiphertext, + secretValueIV: req.body.secretValueIV, + secretValueTag: req.body.secretValueTag, + secretValueCiphertext: req.body.secretValueCiphertext, + secretCommentIV: req.body.secretCommentIV, + secretCommentTag: req.body.secretCommentTag, + secretCommentCiphertext: req.body.secretCommentCiphertext, + skipMultilineEncoding: req.body.skipMultilineEncoding, + metadata: req.body.metadata + }); + + return { secret }; + } + }); + + server.route({ + url: "/:secretName", + method: "PATCH", + schema: { + params: z.object({ + secretName: z.string() + }), + body: z.object({ + workspaceId: z.string().trim(), + environment: z.string().trim(), + secretId: z.string().trim().optional(), + type: z.nativeEnum(SecretType).default(SecretType.Shared), + secretPath: z.string().trim().default("/"), + secretValueCiphertext: z.string().trim(), + secretValueIV: z.string().trim(), + secretValueTag: z.string().trim(), + secretCommentCiphertext: z.string().trim().optional(), + secretCommentIV: z.string().trim().optional(), + secretCommentTag: z.string().trim().optional(), + secretReminderRepeatDays: z.number().min(1).max(365).optional().nullable(), + secretReminderNote: z.string().trim().nullable().optional(), + tags: z.string().array().optional(), + skipMultilineEncoding: z.boolean().optional(), + // to update secret name + secretName: z.string().trim().optional(), + secretKeyIV: z.string().trim().optional(), + secretKeyTag: z.string().trim().optional(), + secretKeyCiphertext: z.string().trim().optional(), + metadata: z.record(z.string()).optional() + }), + response: { + 200: z.object({ + secret: SecretsSchema.omit({ secretBlindIndex: true }) + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.API_KEY]), + handler: async (req) => { + const secret = await server.services.secret.updateSecret({ + actorId: req.permission.id, + actor: req.permission.type, + path: req.body.secretPath, + type: req.body.type, + environment: req.body.environment, + secretName: req.params.secretName, + projectId: req.body.workspaceId, + secretKeyIV: req.body.secretKeyIV, + secretKeyTag: req.body.secretKeyTag, + secretKeyCiphertext: req.body.secretKeyCiphertext, + secretValueIV: req.body.secretValueIV, + secretValueTag: req.body.secretValueTag, + secretValueCiphertext: req.body.secretValueCiphertext, + secretCommentIV: req.body.secretCommentIV, + secretCommentTag: req.body.secretCommentTag, + secretCommentCiphertext: req.body.secretCommentCiphertext, + skipMultilineEncoding: req.body.skipMultilineEncoding, + metadata: req.body.metadata, + secretReminderRepeatDays: req.body.secretReminderRepeatDays, + secretReminderNote: req.body.secretReminderNote, + newSecretName: req.body.secretName + }); + + return { secret }; + } + }); + + server.route({ + url: "/:secretName", + method: "DELETE", + schema: { + params: z.object({ + secretName: z.string() + }), + body: z.object({ + type: z.nativeEnum(SecretType).default(SecretType.Shared), + secretPath: z.string().trim().default("/"), + secretId: z.string().trim().optional(), + workspaceId: z.string().trim(), + environment: z.string().trim() + }), + response: { + 200: z.object({ + secret: SecretsSchema.omit({ secretBlindIndex: true }) + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.API_KEY]), + handler: async (req) => { + const secret = await server.services.secret.deleteSecret({ + actorId: req.permission.id, + actor: req.permission.type, + path: req.body.secretPath, + type: req.body.type, + environment: req.body.environment, + secretName: req.params.secretName, + projectId: req.body.workspaceId, + secretId: req.body.secretId + }); + + return { secret }; + } + }); + + server.route({ + url: "/batch", + method: "POST", + schema: { + body: z.object({ + workspaceId: z.string().trim(), + environment: z.string().trim(), + secretPath: z.string().trim().default("/"), + secrets: z + .object({ + secretName: z.string().trim(), + type: z.nativeEnum(SecretType).default(SecretType.Shared), + secretKeyCiphertext: z.string().trim(), + secretKeyIV: z.string().trim(), + secretKeyTag: z.string().trim(), + secretValueCiphertext: z.string().trim(), + secretValueIV: z.string().trim(), + secretValueTag: z.string().trim(), + secretCommentCiphertext: z.string().trim().optional(), + secretCommentIV: z.string().trim().optional(), + secretCommentTag: z.string().trim().optional(), + metadata: z.record(z.string()).optional(), + skipMultilineEncoding: z.boolean().optional() + }) + .array() + .min(1) + }), + response: { + 200: z.object({ + secrets: SecretsSchema.omit({ secretBlindIndex: true }).array() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.API_KEY]), + handler: async (req) => { + const secrets = await server.services.secret.createManySecret({ + actorId: req.permission.id, + actor: req.permission.type, + path: req.body.secretPath, + environment: req.body.environment, + projectId: req.body.workspaceId, + secrets: req.body.secrets + }); + + return { secrets }; + } + }); + + server.route({ + url: "/batch", + method: "PATCH", + schema: { + body: z.object({ + workspaceId: z.string().trim(), + environment: z.string().trim(), + secretPath: z.string().trim().default("/"), + secrets: z + .object({ + secretName: z.string().trim(), + type: z.nativeEnum(SecretType).default(SecretType.Shared), + secretValueCiphertext: z.string().trim(), + secretValueIV: z.string().trim(), + secretValueTag: z.string().trim(), + secretKeyCiphertext: z.string().trim(), + secretKeyIV: z.string().trim(), + secretKeyTag: z.string().trim(), + secretCommentCiphertext: z.string().trim().optional(), + secretCommentIV: z.string().trim().optional(), + secretCommentTag: z.string().trim().optional(), + skipMultilineEncoding: z.boolean().optional(), + tags: z.string().array().optional() + }) + .array() + .min(1) + }), + response: { + 200: z.object({ + secrets: SecretsSchema.omit({ secretBlindIndex: true }).array() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.API_KEY]), + handler: async (req) => { + const secrets = await server.services.secret.updateManySecret({ + actorId: req.permission.id, + actor: req.permission.type, + path: req.body.secretPath, + environment: req.body.environment, + projectId: req.body.workspaceId, + secrets: req.body.secrets + }); + + return { secrets }; + } + }); + + server.route({ + url: "/batch", + method: "DELETE", + schema: { + body: z.object({ + workspaceId: z.string().trim(), + environment: z.string().trim(), + secretPath: z.string().trim().default("/"), + secrets: z + .object({ + secretName: z.string().trim(), + type: z.nativeEnum(SecretType).default(SecretType.Shared) + }) + .array() + .min(1) + }), + response: { + 200: z.object({ + secrets: SecretsSchema.omit({ secretBlindIndex: true }).array() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.API_KEY]), + handler: async (req) => { + const secrets = await server.services.secret.deleteManySecret({ + actorId: req.permission.id, + actor: req.permission.type, + path: req.body.secretPath, + environment: req.body.environment, + projectId: req.body.workspaceId, + secrets: req.body.secrets + }); + + return { secrets }; + } + }); +}; diff --git a/backend-pg/src/services/auth/auth-password-service.ts b/backend-pg/src/services/auth/auth-password-service.ts index 1979f1710..d44b6c602 100644 --- a/backend-pg/src/services/auth/auth-password-service.ts +++ b/backend-pg/src/services/auth/auth-password-service.ts @@ -1,5 +1,6 @@ import jwt from "jsonwebtoken"; +import { SecretEncryptionAlgo, SecretKeyEncoding } from "@app/db/schemas"; import { getConfig } from "@app/lib/config/env"; import { generateSrpServerKey, srpCheckClientProof } from "@app/lib/crypto"; @@ -14,7 +15,6 @@ import { TResetPasswordViaBackupKeyDTO } from "./auth-password-type"; import { AuthTokenType } from "./auth-type"; -import { SecretEncryptionAlgo, SecretKeyEncoding } from "@app/db/schemas"; type TAuthPasswordServiceFactoryDep = { authDal: TAuthDalFactory; diff --git a/backend-pg/src/services/project-env/project-env-dal.ts b/backend-pg/src/services/project-env/project-env-dal.ts index 917a71eba..fb4c70307 100644 --- a/backend-pg/src/services/project-env/project-env-dal.ts +++ b/backend-pg/src/services/project-env/project-env-dal.ts @@ -1,10 +1,63 @@ +import { Knex } from "knex"; + import { TDbClient } from "@app/db"; import { TableName } from "@app/db/schemas"; +import { DatabaseError } from "@app/lib/errors"; import { ormify } from "@app/lib/knex"; export type TProjectEnvDalFactory = ReturnType; export const projectEnvDalFactory = (db: TDbClient) => { const projectEnvOrm = ormify(db, TableName.Environment); - return projectEnvOrm; + + const findBySlugs = async (projectId: string, env: string[], tx?: Knex) => { + try { + const envs = await (tx || db)(TableName.Environment) + .where("projectId", projectId) + .whereIn("slug", env); + return envs; + } catch (error) { + throw new DatabaseError({ error, name: "Find by slugs" }); + } + }; + + // we are using postion based sorting as its a small list + // this will return the last value of the position in a folder with secret imports + const findLastEnvPosition = async (projectId: string, tx?: Knex) => { + const lastPos = await (tx || db)(TableName.Environment) + .where({ projectId }) + .max("position") + .first(); + return lastPos?.position || 1; + }; + + const incrementLastPosition = async ( + projectId: string, + startPos: number, + increment = 1, + tx?: Knex + ) => + (tx || db)(TableName.Environment) + .where("projectId", projectId) + .where("postion", ">=", startPos) + .increment("position", increment); + + const decrementLastPosition = async ( + projectId: string, + startPos: number, + decrement = 1, + tx?: Knex + ) => + (tx || db)(TableName.Environment) + .where("projectId", projectId) + .where("postion", ">", startPos) + .decrement("position", decrement); + + return { + ...projectEnvOrm, + findBySlugs, + findLastEnvPosition, + decrementLastPosition, + incrementLastPosition + }; }; diff --git a/backend-pg/src/services/project-env/project-env-service.ts b/backend-pg/src/services/project-env/project-env-service.ts index 588133d20..ecb15cdf3 100644 --- a/backend-pg/src/services/project-env/project-env-service.ts +++ b/backend-pg/src/services/project-env/project-env-service.ts @@ -8,7 +8,7 @@ import { import { BadRequestError } from "@app/lib/errors"; import { TProjectEnvDalFactory } from "./project-env-dal"; -import { TCreateEnvDTO, TDeleteEnvDTO, TUpdateEnvDTO } from "./project-env-types"; +import { TCreateEnvDTO, TDeleteEnvDTO, TReorderEnvDTO, TUpdateEnvDTO } from "./project-env-types"; type TProjectEnvServiceFactoryDep = { projectEnvDal: TProjectEnvDalFactory; @@ -36,7 +36,11 @@ export const projectEnvServiceFactory = ({ name: "Create envv" }); - const env = await projectEnvDal.create({ slug, name, projectId }); + const env = await projectEnvDal.transaction(async (tx) => { + const lastPos = await projectEnvDal.findLastEnvPosition(projectId, tx); + const doc = await projectEnvDal.create({ slug, name, projectId, position: lastPos }, tx); + return doc; + }); return env; }; @@ -75,13 +79,44 @@ export const projectEnvServiceFactory = ({ ProjectPermissionSub.Environments ); - const [env] = await projectEnvDal.delete({ id, projectId }); + const env = await projectEnvDal.transaction(async (tx) => { + const [doc] = await projectEnvDal.delete({ id, projectId }, tx); + if (!doc) + throw new BadRequestError({ + message: "Env doesn't exist", + name: "Re-order env" + }); + + await projectEnvDal.decrementLastPosition(projectId, doc.position, 1, tx); + return doc; + }); + return env; + }; + + const reorderEnvironment = async ({ projectId, id, actorId, actor, pos }: TReorderEnvDTO) => { + const { permission } = await permissionService.getProjectPermission(actor, actorId, projectId); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Edit, + ProjectPermissionSub.Environments + ); + + const [env] = await projectEnvDal.transaction(async (tx) => { + await projectEnvDal.incrementLastPosition(projectId, pos, 1, tx); + return projectEnvDal.update({ id, projectId }, { position: pos }, tx); + }); + if (!env) + throw new BadRequestError({ + message: "Env doesn't exist", + name: "Re-order env" + }); + return env; }; return { createEnvironment, updateEnvironment, - deleteEnvironment + deleteEnvironment, + reorderEnvironment }; }; diff --git a/backend-pg/src/services/project-env/project-env-types.ts b/backend-pg/src/services/project-env/project-env-types.ts index aeea4b10f..d7265ea04 100644 --- a/backend-pg/src/services/project-env/project-env-types.ts +++ b/backend-pg/src/services/project-env/project-env-types.ts @@ -14,3 +14,8 @@ export type TUpdateEnvDTO = { export type TDeleteEnvDTO = { id: string; } & TProjectPermission; + +export type TReorderEnvDTO = { + id: string; + pos: number; +} & TProjectPermission; diff --git a/backend-pg/src/services/project/project-service.ts b/backend-pg/src/services/project/project-service.ts index 7c59a5328..febf29502 100644 --- a/backend-pg/src/services/project/project-service.ts +++ b/backend-pg/src/services/project/project-service.ts @@ -10,9 +10,13 @@ import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; +import { getConfig } from "@app/lib/config/env"; +import { createSecretBlindIndex } from "@app/lib/crypto"; import { TProjectEnvDalFactory } from "../project-env/project-env-dal"; import { TProjectMembershipDalFactory } from "../project-membership/project-membership-dal"; +import { TSecretBlindIndexDalFactory } from "../secret/secret-blind-index-dal"; +import { ROOT_FOLDER_NAME, TSecretFolderDalFactory } from "../secret-folder/secret-folder-dal"; import { TProjectDalFactory } from "./project-dal"; import { TCreateProjectDTO, TDeleteProjectDTO, TGetProjectDTO } from "./project-types"; @@ -24,8 +28,10 @@ const DEFAULT_PROJECT_ENVS = [ type TProjectServiceFactoryDep = { projectDal: TProjectDalFactory; + folderDal: Pick; projectEnvDal: Pick; projectMembershipDal: Pick; + secretBlindIndexDal: Pick; permissionService: TPermissionServiceFactory; }; @@ -34,6 +40,8 @@ export type TProjectServiceFactory = ReturnType; export const projectServiceFactory = ({ projectDal, permissionService, + folderDal, + secretBlindIndexDal, projectMembershipDal, projectEnvDal }: TProjectServiceFactoryDep) => { @@ -47,9 +55,12 @@ export const projectServiceFactory = ({ OrgPermissionSubjects.Workspace ); + const appCfg = getConfig(); + const blindIndex = createSecretBlindIndex(appCfg.ROOT_ENCRYPTION_KEY, appCfg.ENCRYPTION_KEY); // TODO(backend-pg): licence server const newProject = projectDal.transaction(async (tx) => { const project = await projectDal.create({ name: workspaceName, orgId }, tx); + // set user as admin member for proeject await projectMembershipDal.create( { userId: actorId, @@ -58,8 +69,25 @@ export const projectServiceFactory = ({ }, tx ); + // generate the blind index for project + await secretBlindIndexDal.create( + { + projectId: project.id, + keyEncoding: blindIndex.keyEncoding, + saltIV: blindIndex.iv, + saltTag: blindIndex.tag, + algorithm: blindIndex.algorithm, + encryptedSaltCipherText: blindIndex.ciphertext + }, + tx + ); + // set default environments and root folder for provided environments const envs = await projectEnvDal.insertMany( - DEFAULT_PROJECT_ENVS.map((el) => ({ ...el, projectId: project.id })), + DEFAULT_PROJECT_ENVS.map((el, i) => ({ ...el, projectId: project.id, position: i + 1 })), + tx + ); + await folderDal.insertMany( + envs.map(({ id }) => ({ name: ROOT_FOLDER_NAME, envId: id, version: 1 })), tx ); return { ...project, environments: envs }; diff --git a/backend-pg/src/services/secret-folder/secret-folder-dal.ts b/backend-pg/src/services/secret-folder/secret-folder-dal.ts new file mode 100644 index 000000000..ba2490208 --- /dev/null +++ b/backend-pg/src/services/secret-folder/secret-folder-dal.ts @@ -0,0 +1,40 @@ +import { Knex } from "knex"; + +import { TDbClient } from "@app/db"; +import { TableName,TSecretFolders } from "@app/db/schemas"; +import { DatabaseError } from "@app/lib/errors"; +import { ormify } from "@app/lib/knex"; + +export type TSecretFolderDalFactory = ReturnType; +// never change this. If u do write a migration for it +export const ROOT_FOLDER_NAME = "root"; +export const secretFolderDalFactory = (db: TDbClient) => { + const secretFolderOrm = ormify(db, TableName.SecretFolder); + + const findBySecretPath = async ( + projectId: string, + environment: string, + path: string, + tx?: Knex + ) => { + try { + const folder: TSecretFolders | undefined = await (tx || db)(TableName.SecretFolder) + .join( + TableName.Environment, + `${TableName.SecretFolder}.envId`, + `${TableName.Environment}.id` + ) + .join(TableName.Project, `${TableName.Environment}.projectId`, `${TableName.Project}.id`) + .where(`${TableName.Project}.id`, projectId) + .where(`${TableName.Environment}.slug`, environment) + .where(`${TableName.SecretFolder}.name`, "root") + .select(`${TableName.SecretFolder}.*`) + .first(); + return folder; + } catch (error) { + throw new DatabaseError({ error, name: "Find by secret path" }); + } + }; + + return { ...secretFolderOrm, findBySecretPath }; +}; diff --git a/backend-pg/src/services/secret-folder/secret-folder-service.ts b/backend-pg/src/services/secret-folder/secret-folder-service.ts new file mode 100644 index 000000000..1d0bc47c9 --- /dev/null +++ b/backend-pg/src/services/secret-folder/secret-folder-service.ts @@ -0,0 +1,132 @@ +import { ForbiddenError, subject } from "@casl/ability"; + +import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; +import { + ProjectPermissionActions, + ProjectPermissionSub +} from "@app/ee/services/permission/project-permission"; +import { BadRequestError } from "@app/lib/errors"; + +import { TProjectEnvDalFactory } from "../project-env/project-env-dal"; +import { ROOT_FOLDER_NAME, TSecretFolderDalFactory } from "./secret-folder-dal"; +import { + TCreateFolderDTO, + TDeleteFolderDTO, + TGetFolderDTO, + TUpdateFolderDTO +} from "./secret-folder-types"; + +type TSecretFolderServiceFactoryDep = { + permissionService: Pick; + folderDal: TSecretFolderDalFactory; + projectEnvDal: Pick; +}; + +export type TSecretFolderServiceFactory = ReturnType; + +export const secretFolderServiceFactory = ({ + folderDal, + permissionService, + projectEnvDal +}: TSecretFolderServiceFactoryDep) => { + const createFolder = async ({ + projectId, + actor, + actorId, + name, + environment, + path + }: TCreateFolderDTO) => { + const { permission } = await permissionService.getProjectPermission(actor, actorId, projectId); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Create, + subject(ProjectPermissionSub.Secrets, { environment, secretPath: path }) + ); + + const env = await projectEnvDal.findOne({ projectId, slug: environment }); + if (!env) + throw new BadRequestError({ message: "Environment not found", name: "Create folder" }); + + const folder = await folderDal.transaction(async (tx) => { + const doc = await folderDal.create({ name, envId: env.id, version: 1 }, tx); + return doc; + }); + + return folder; + }; + + const updateFolder = async ({ + projectId, + actor, + actorId, + name, + environment, + path, + id + }: TUpdateFolderDTO) => { + const { permission } = await permissionService.getProjectPermission(actor, actorId, projectId); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Edit, + subject(ProjectPermissionSub.Secrets, { environment, secretPath: path }) + ); + + const env = await projectEnvDal.findOne({ projectId, slug: environment }); + if (!env) + throw new BadRequestError({ message: "Environment not found", name: "Create folder" }); + + const folder = await folderDal.transaction(async (tx) => { + const [doc] = await folderDal.update({ envId: env.id, id }, { name, version: 1 }, tx); + if (!doc) throw new BadRequestError({ message: "Folder not found", name: "Update folder" }); + return doc; + }); + + return folder; + }; + + const deleteFolder = async ({ + projectId, + actor, + actorId, + environment, + path, + id + }: TDeleteFolderDTO) => { + const { permission } = await permissionService.getProjectPermission(actor, actorId, projectId); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Delete, + subject(ProjectPermissionSub.Secrets, { environment, secretPath: path }) + ); + + const env = await projectEnvDal.findOne({ projectId, slug: environment }); + if (!env) + throw new BadRequestError({ message: "Environment not found", name: "Create folder" }); + + const folder = await folderDal.transaction(async (tx) => { + const [doc] = await folderDal.delete({ envId: env.id, id }, tx); + if (!doc) throw new BadRequestError({ message: "Folder not found", name: "Delete folder" }); + return doc; + }); + + return folder; + }; + + const getFolders = async ({ projectId, actor, actorId, environment }: TGetFolderDTO) => { + // folder list is allowed to be read by anyone + // permission to check does user has access + await permissionService.getProjectPermission(actor, actorId, projectId); + + const env = await projectEnvDal.findOne({ projectId, slug: environment }); + if (!env) + throw new BadRequestError({ message: "Environment not found", name: "Create folder" }); + + const folders = await folderDal.find({ envId: env.id, parentId: null }); + return folders.filter(({ name }) => name !== ROOT_FOLDER_NAME); + }; + + return { + createFolder, + updateFolder, + deleteFolder, + getFolders + }; +}; diff --git a/backend-pg/src/services/secret-folder/secret-folder-types.ts b/backend-pg/src/services/secret-folder/secret-folder-types.ts new file mode 100644 index 000000000..7a68434f5 --- /dev/null +++ b/backend-pg/src/services/secret-folder/secret-folder-types.ts @@ -0,0 +1,25 @@ +import { TProjectPermission } from "@app/lib/types"; + +export type TCreateFolderDTO = { + environment: string; + path: string; + name: string; +} & TProjectPermission; + +export type TUpdateFolderDTO = { + environment: string; + path: string; + id: string; + name: string; +} & TProjectPermission; + +export type TDeleteFolderDTO = { + environment: string; + path: string; + id: string; +} & TProjectPermission; + +export type TGetFolderDTO = { + environment: string; + path: string; +} & TProjectPermission; diff --git a/backend-pg/src/services/secret-import/secret-import-dal.ts b/backend-pg/src/services/secret-import/secret-import-dal.ts new file mode 100644 index 000000000..cfcfbab23 --- /dev/null +++ b/backend-pg/src/services/secret-import/secret-import-dal.ts @@ -0,0 +1,83 @@ +import { Knex } from "knex"; + +import { TDbClient } from "@app/db"; +import { TableName,TSecretImports } from "@app/db/schemas"; +import { DatabaseError } from "@app/lib/errors"; +import { ormify } from "@app/lib/knex"; + +export type TSecretImportDalFactory = ReturnType; + +export const secretImportDalFactory = (db: TDbClient) => { + const secretImportOrm = ormify(db, TableName.SecretImport); + + // we are using postion based sorting as its a small list + // this will return the last value of the position in a folder with secret imports + const findLastImportPosition = async (folderId: string, tx?: Knex) => { + const lastPos = await (tx || db)(TableName.SecretImport) + .where({ folderId }) + .max({ position: "position" }) + .first(); + return lastPos?.position || 0; + }; + + const updateAllPosition = async (folderId: string, pos: number, targetPos: number, tx?: Knex) => { + try { + if (targetPos === -1) { + // this means delete + await (tx || db)(TableName.SecretImport) + .where({ folderId }) + .andWhere("position", ">", pos) + .decrement("position", 1); + return; + } + + if (targetPos > pos) { + await (tx || db)(TableName.SecretImport) + .where({ folderId }) + .where("position", "<=", targetPos) + .andWhere("position", ">", pos) + .decrement("position", 1); + } else { + await (tx || db)(TableName.SecretImport) + .where({ folderId }) + .where("position", ">=", targetPos) + .andWhere("position", "<", pos) + .increment("position", 1); + } + } catch (error) { + throw new DatabaseError({ error, name: "Update position" }); + } + }; + + const find = async (filter: Partial, tx?: Knex) => { + try { + const docs = await (tx || db)(TableName.SecretImport) + .where(filter) + .join( + TableName.Environment, + `${TableName.SecretImport}.importEnv`, + `${TableName.Environment}.id` + ) + .select( + db.ref("*").withSchema(TableName.SecretImport) as unknown as keyof TSecretImports, + db.ref("slug").withSchema(TableName.Environment), + db.ref("name").withSchema(TableName.Environment), + db.ref("id").withSchema(TableName.Environment).as("envId") + ) + .orderBy("position", "asc"); + return docs.map(({ envId, slug, name, ...el }) => ({ + ...el, + importEnv: { id: envId, slug, name } + })); + } catch (error) { + throw new DatabaseError({ error, name: "Find secret imports" }); + } + }; + + return { + ...secretImportOrm, + find, + findLastImportPosition, + updateAllPosition + }; +}; diff --git a/backend-pg/src/services/secret-import/secret-import-service.ts b/backend-pg/src/services/secret-import/secret-import-service.ts new file mode 100644 index 000000000..80267283a --- /dev/null +++ b/backend-pg/src/services/secret-import/secret-import-service.ts @@ -0,0 +1,177 @@ +import { ForbiddenError, subject } from "@casl/ability"; + +import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; +import { + ProjectPermissionActions, + ProjectPermissionSub +} from "@app/ee/services/permission/project-permission"; +import { BadRequestError } from "@app/lib/errors"; + +import { TProjectEnvDalFactory } from "../project-env/project-env-dal"; +import { TSecretFolderDalFactory } from "../secret-folder/secret-folder-dal"; +import { TSecretImportDalFactory } from "./secret-import-dal"; +import { + TCreateSecretImportDTO, + TDeleteSecretImportDTO, + TGetSecretImportsDTO, + TUpdateSecretImportDTO +} from "./secret-import-types"; + +type TSecretImportServiceFactoryDep = { + secretImportDal: TSecretImportDalFactory; + folderDal: TSecretFolderDalFactory; + projectEnvDal: TProjectEnvDalFactory; + permissionService: Pick; +}; + +const ERR_SEC_IMP_NOT_FOUND = new BadRequestError({ message: "Secret import not found" }); + +export type TSecretImportServiceFactory = ReturnType; + +export const secretImportServiceFactory = ({ + secretImportDal, + projectEnvDal, + permissionService, + folderDal +}: TSecretImportServiceFactoryDep) => { + const createImport = async ({ + environment, + data, + actor, + actorId, + projectId, + path + }: TCreateSecretImportDTO) => { + const { permission } = await permissionService.getProjectPermission(actor, actorId, projectId); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Create, + subject(ProjectPermissionSub.Secrets, { environment, secretPath: path }) + ); + + const folder = await folderDal.findBySecretPath(projectId, environment, path); + if (!folder) throw new BadRequestError({ message: "Folder not found", name: "Create import" }); + + const [importEnv] = await projectEnvDal.findBySlugs(projectId, [data.environment]); + if (!importEnv) + throw new BadRequestError({ error: "Imported env not found", name: "Create import" }); + + const secImport = await secretImportDal.transaction(async (tx) => { + const lastPos = await secretImportDal.findLastImportPosition(folder.id, tx); + return secretImportDal.create( + { + folderId: folder.id, + position: lastPos + 1, + importEnv: importEnv.id, + importPath: data.path + }, + tx + ); + }); + + return { ...secImport, importEnv }; + }; + + const updateImport = async ({ + path, + environment, + projectId, + actor, + actorId, + data, + id + }: TUpdateSecretImportDTO) => { + const { permission } = await permissionService.getProjectPermission(actor, actorId, projectId); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Edit, + subject(ProjectPermissionSub.Secrets, { environment, secretPath: path }) + ); + + const folder = await folderDal.findBySecretPath(projectId, environment, path); + if (!folder) throw new BadRequestError({ message: "Folder not found", name: "Update import" }); + + const secImpDoc = await secretImportDal.findOne({ folderId: folder.id, id }); + if (!secImpDoc) throw ERR_SEC_IMP_NOT_FOUND; + + const importedEnv = data.environment // this is get env information of new one or old one + ? (await projectEnvDal.findBySlugs(projectId, [data.environment]))?.[0] + : await projectEnvDal.findById(secImpDoc.importEnv); + if (!importedEnv) + throw new BadRequestError({ error: "Imported env not found", name: "Create import" }); + + const updatedSecImport = await secretImportDal.transaction(async (tx) => { + const secImp = await secretImportDal.findOne({ folderId: folder.id, id }); + if (!secImp) throw ERR_SEC_IMP_NOT_FOUND; + if (data.position) { + await secretImportDal.updateAllPosition(folder.id, secImp.position, data.position, tx); + } + const [doc] = await secretImportDal.update( + { id, folderId: folder.id }, + { + position: data?.position, + importEnv: data?.environment ? importedEnv.id : undefined, + importPath: data?.path + }, + tx + ); + return doc; + }); + return { ...updatedSecImport, importEnv: importedEnv }; + }; + + const deleteImport = async ({ + path, + environment, + projectId, + actor, + actorId, + id + }: TDeleteSecretImportDTO) => { + const { permission } = await permissionService.getProjectPermission(actor, actorId, projectId); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Delete, + subject(ProjectPermissionSub.Secrets, { environment, secretPath: path }) + ); + + const folder = await folderDal.findBySecretPath(projectId, environment, path); + if (!folder) throw new BadRequestError({ message: "Folder not found", name: "Delete import" }); + + const secImport = await secretImportDal.transaction(async (tx) => { + const [doc] = await secretImportDal.delete({ folderId: folder.id, id }, tx); + if (!doc) + throw new BadRequestError({ name: "Sec imp del", message: "Secret import doc not found" }); + await secretImportDal.updateAllPosition(folder.id, doc.position, -1, tx); + + const importEnv = await projectEnvDal.findById(doc.importEnv); + if (!importEnv) + throw new BadRequestError({ error: "Imported env not found", name: "Create import" }); + return { ...doc, importEnv }; + }); + return secImport; + }; + + const getImports = async ({ + path, + environment, + projectId, + actor, + actorId + }: TGetSecretImportsDTO) => { + const { permission } = await permissionService.getProjectPermission(actor, actorId, projectId); + 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", name: "Get imports" }); + + const secImports = await secretImportDal.find({ folderId: folder.id }); + return secImports; + }; + return { + createImport, + updateImport, + deleteImport, + getImports + }; +}; diff --git a/backend-pg/src/services/secret-import/secret-import-types.ts b/backend-pg/src/services/secret-import/secret-import-types.ts new file mode 100644 index 000000000..c04f1c994 --- /dev/null +++ b/backend-pg/src/services/secret-import/secret-import-types.ts @@ -0,0 +1,28 @@ +import { TProjectPermission } from "@app/lib/types"; + +export type TCreateSecretImportDTO = { + environment: string; + path: string; + data: { + environment: string; + path: string; + }; +} & TProjectPermission; + +export type TUpdateSecretImportDTO = { + environment: string; + path: string; + id: string; + data: Partial<{ environment: string; path: string; position: number }>; +} & TProjectPermission; + +export type TDeleteSecretImportDTO = { + environment: string; + path: string; + id: string; +} & TProjectPermission; + +export type TGetSecretImportsDTO = { + environment: string; + path: string; +} & TProjectPermission; diff --git a/backend-pg/src/services/secret/secret-blind-index-dal.ts b/backend-pg/src/services/secret/secret-blind-index-dal.ts new file mode 100644 index 000000000..99c0c68c2 --- /dev/null +++ b/backend-pg/src/services/secret/secret-blind-index-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 TSecretBlindIndexDalFactory = ReturnType; + +export const secretBlindIndexDalFactory = (db: TDbClient) => { + const secretBlindIndexOrm = ormify(db, TableName.SecretBlindIndex); + return secretBlindIndexOrm; +}; diff --git a/backend-pg/src/services/secret/secret-dal.ts b/backend-pg/src/services/secret/secret-dal.ts new file mode 100644 index 000000000..2e84bcc04 --- /dev/null +++ b/backend-pg/src/services/secret/secret-dal.ts @@ -0,0 +1,110 @@ +import { Knex } from "knex"; + +import { TDbClient } from "@app/db"; +import { SecretType, TableName, TSecrets, TSecretsInsert,TSecretsUpdate } from "@app/db/schemas"; +import { DatabaseError } from "@app/lib/errors"; +import { ormify } from "@app/lib/knex"; + +export type TSecretDalFactory = ReturnType; + +export const secretDalFactory = (db: TDbClient) => { + const secretOrm = ormify(db, TableName.Secret); + + const update = async ( + filter: Partial, + data: Omit, + tx?: Knex + ) => { + try { + const sec = await (tx || db)(TableName.Secret) + .where(filter) + .update(data) + .increment("version", 1) + .returning("*"); + return sec; + } catch (error) { + throw new DatabaseError({ error, name: "update secret" }); + } + }; + + // 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, tx?: Knex) => { + try { + const secs = await (tx || db)(TableName.Secret) + .insert(data as TSecretsInsert[]) + .onConflict("id") + .merge() + .returning("*"); + return secs; + } catch (error) { + throw new DatabaseError({ error, name: "bulk update secret" }); + } + }; + + const deleteMany = async ( + data: Array<{ blindIndex: string; type: SecretType }>, + folderId: string, + userId: string, + tx?: Knex + ) => { + try { + const deletedSecrets = await (tx || db)(TableName.Secret) + .where({ folderId }) + .where((bd) => { + data.forEach((el) => { + bd.orWhere({ + secretBlindIndex: el.blindIndex, + type: el.type, + userId: el.type === SecretType.Personal ? userId : null + }); + }); + }) + .delete() + .returning("*"); + return deletedSecrets; + } catch (error) { + throw new DatabaseError({ error, name: "delete many secret" }); + } + }; + + const findByFolderId = async (folderId: string, userId?: string, tx?: Knex) => { + try { + const sec = await (tx || db)(TableName.Secret) + .where({ folderId }) + .where((bd) => { + bd.whereNull("userId").orWhere({ userId: userId || null }); + }); + return sec; + } catch (error) { + throw new DatabaseError({ error, name: "get all secret" }); + } + }; + + const findByBlindIndexes = async ( + folderId: string, + blindIndexes: Array<{ blindIndex: string; type: SecretType }>, + userId?: string, + tx?: Knex + ) => { + if (!blindIndexes.length) return []; + try { + const secrets = await (tx || db)(TableName.Secret) + .where({ folderId }) + .where((bd) => { + blindIndexes.forEach((el) => { + bd.orWhere({ + secretBlindIndex: el.blindIndex, + type: el.type, + userId: el.type === SecretType.Personal ? userId : null + }); + }); + }); + return secrets; + } catch (error) { + throw new DatabaseError({ error, name: "find by blind indexes" }); + } + }; + + return { ...secretOrm, update, bulkUpdate, deleteMany, findByFolderId, findByBlindIndexes }; +}; diff --git a/backend-pg/src/services/secret/secret-service.ts b/backend-pg/src/services/secret/secret-service.ts new file mode 100644 index 000000000..110cbf056 --- /dev/null +++ b/backend-pg/src/services/secret/secret-service.ts @@ -0,0 +1,589 @@ +import { ForbiddenError, subject } from "@casl/ability"; + +import { + SecretEncryptionAlgo, + SecretKeyEncoding, + SecretType, + TSecretBlindIndexes, + TSecrets +} from "@app/db/schemas"; +import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; +import { + ProjectPermissionActions, + ProjectPermissionSub +} from "@app/ee/services/permission/project-permission"; +import { getConfig } from "@app/lib/config/env"; +import { buildSecretBlindIndexFromName } from "@app/lib/crypto"; +import { BadRequestError } from "@app/lib/errors"; + +import { TSecretFolderDalFactory } from "../secret-folder/secret-folder-dal"; +import { TSecretBlindIndexDalFactory } from "./secret-blind-index-dal"; +import { TSecretDalFactory } from "./secret-dal"; +import { + TCreateBulkSecretDTO, + TCreateSecretDTO, + TDeleteBulkSecretDTO, + TDeleteSecretDTO, + TGetASecretDTO, + TGetSecretsDTO, + TUpdateBulkSecretDTO, + TUpdateSecretDTO +} from "./secret-types"; +import { TSecretVersionDalFactory } from "./secret-version-dal"; + +type TSecretServiceFactoryDep = { + secretDal: TSecretDalFactory; + secretVersionDal: TSecretVersionDalFactory; + folderDal: TSecretFolderDalFactory; + secretBlindIndexDal: TSecretBlindIndexDalFactory; + permissionService: Pick; +}; + +export type TSecretServiceFactory = ReturnType; + +export const secretServiceFactory = ({ + secretDal, + secretVersionDal, + folderDal, + secretBlindIndexDal, + permissionService +}: TSecretServiceFactoryDep) => { + const generateSecretBlindIndexBySalt = async ( + secretName: string, + secretBlindIndexDoc: TSecretBlindIndexes + ) => { + const appCfg = getConfig(); + const secretBlindIndex = await buildSecretBlindIndexFromName({ + secretName, + keyEncoding: secretBlindIndexDoc.keyEncoding as SecretKeyEncoding, + rootEncryptionKey: appCfg.ROOT_ENCRYPTION_KEY, + encryptionKey: appCfg.ENCRYPTION_KEY, + tag: secretBlindIndexDoc.saltTag, + ciphertext: secretBlindIndexDoc.encryptedSaltCipherText, + iv: secretBlindIndexDoc.saltIV + }); + return secretBlindIndex; + }; + + // utility function to get secret blind index data + const generateSecretBlindIndexByName = async (projectId: string, secretName: string) => { + const appCfg = getConfig(); + + const secretBlindIndexDoc = await secretBlindIndexDal.findOne({ projectId }); + if (!secretBlindIndexDoc) + throw new BadRequestError({ message: "Blind index not found", name: "Create secret" }); + + const secretBlindIndex = await buildSecretBlindIndexFromName({ + secretName, + keyEncoding: secretBlindIndexDoc.keyEncoding as SecretKeyEncoding, + rootEncryptionKey: appCfg.ROOT_ENCRYPTION_KEY, + encryptionKey: appCfg.ENCRYPTION_KEY, + tag: secretBlindIndexDoc.saltTag, + ciphertext: secretBlindIndexDoc.encryptedSaltCipherText, + iv: secretBlindIndexDoc.saltIV + }); + if (!secretBlindIndex) throw new BadRequestError({ message: "Secret not found" }); + return secretBlindIndex; + }; + + const createSecret = async ({ + path, + actor, + actorId, + environment, + projectId, + ...inputSecret + }: TCreateSecretDTO) => { + const { permission } = await permissionService.getProjectPermission(actor, actorId, projectId); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Create, + subject(ProjectPermissionSub.Secrets, { environment, secretPath: path }) + ); + + const secretBlindIndex = await generateSecretBlindIndexByName( + projectId, + inputSecret.secretName + ); + const folder = await folderDal.findBySecretPath(projectId, environment, path); + if (!folder) throw new BadRequestError({ message: "Folder not found", name: "Create secret" }); + const folderId = folder.id; + + const existingSecret = await secretDal.findOne({ + secretBlindIndex, + folderId, + type: inputSecret.type, + userId: inputSecret.type === SecretType.Personal ? actorId : null + }); + if (existingSecret) throw new BadRequestError({ message: "Secret already exist" }); + // if user creating personal check its shared also exist + if (inputSecret.type === SecretType.Personal) { + const sharedExist = await secretDal.findOne({ + secretBlindIndex, + folderId, + type: SecretType.Shared + }); + if (!sharedExist) + throw new BadRequestError({ + message: "Failed to create personal secret override for no corresponding shared secret" + }); + } + + const secret = await secretDal.transaction(async (tx) => { + const { secretName, type, ...el } = inputSecret; + const doc = await secretDal.create( + { + version: 1, + folderId, + secretBlindIndex, + type, + ...el, + userId: inputSecret.type === SecretType.Personal ? actorId : null, + algorithm: SecretEncryptionAlgo.AES_256_GCM, + keyEncoding: SecretKeyEncoding.UTF8 + }, + tx + ); + await secretVersionDal.create( + { + secretBlindIndex, + folderId, + version: 1, + type, + ...el, + userId: inputSecret.type === SecretType.Personal ? actorId : null, + algorithm: SecretEncryptionAlgo.AES_256_GCM, + keyEncoding: SecretKeyEncoding.UTF8, + secretId: doc.id + }, + tx + ); + return doc; + }); + + // TODO(akhilmhdh-pg): licence check, posthog service and snapshot + return secret; + }; + + const updateSecret = async ({ + path, + actor, + actorId, + environment, + projectId, + ...inputSecret + }: TUpdateSecretDTO) => { + const { permission } = await permissionService.getProjectPermission(actor, actorId, projectId); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Edit, + subject(ProjectPermissionSub.Secrets, { environment, secretPath: path }) + ); + + const blindIndexDoc = await secretBlindIndexDal.findOne({ projectId }); + if (!blindIndexDoc) + throw new BadRequestError({ message: "Blind index not found", name: "Update secret" }); + const oldBlindIndex = await generateSecretBlindIndexBySalt( + inputSecret.secretName, + blindIndexDoc + ); + if (!oldBlindIndex) throw new BadRequestError({ message: "Secret not found" }); + + const folder = await folderDal.findBySecretPath(projectId, environment, path); + if (!folder) throw new BadRequestError({ message: "Folder not found", name: "Create secret" }); + const folderId = folder.id; + + let newSecretNameBlindIndex: string; + if (inputSecret?.newSecretName && inputSecret.type === SecretType.Shared) { + newSecretNameBlindIndex = await generateSecretBlindIndexBySalt( + inputSecret.newSecretName, + blindIndexDoc + ); + const doesSecretExist = await secretDal.findOne({ + secretBlindIndex: newSecretNameBlindIndex, + folderId, + type: inputSecret.type + }); + if (doesSecretExist) { + throw new BadRequestError({ message: "Secret with the provided name already exist" }); + } + } + + const updatedSecret = await secretDal.transaction(async (tx) => { + const { secretName, ...el } = inputSecret; + const [doc] = await secretDal.update( + { + secretBlindIndex: oldBlindIndex, + folderId, + type: inputSecret.type, + userId: inputSecret.type === SecretType.Personal ? actorId : null + }, + { + secretBlindIndex: newSecretNameBlindIndex, + ...el + }, + tx + ); + const { id, createdAt, updatedAt, ...newVersion } = doc; + await secretVersionDal.create( + { + userId: inputSecret.type === SecretType.Personal ? actorId : null, + secretId: doc.id, + ...newVersion + }, + tx + ); + return doc; + }); + + // TODO(akhilmhdh-pg): licence check, posthog service and snapshot + return updatedSecret; + }; + + const deleteSecret = async ({ + path, + actor, + actorId, + environment, + projectId, + ...inputSecret + }: TDeleteSecretDTO) => { + const { permission } = await permissionService.getProjectPermission(actor, actorId, projectId); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Delete, + subject(ProjectPermissionSub.Secrets, { environment, secretPath: path }) + ); + + const secretBlindIndex = await generateSecretBlindIndexByName( + projectId, + inputSecret.secretName + ); + + const folder = await folderDal.findBySecretPath(projectId, environment, path); + if (!folder) throw new BadRequestError({ message: "Folder not found", name: "Create secret" }); + const folderId = folder.id; + + const deletedSecret = await secretDal.transaction(async (tx) => { + const [doc] = await secretDal.delete( + { + secretBlindIndex, + folderId, + type: inputSecret.type, + userId: inputSecret.type === SecretType.Personal ? actorId : null + }, + tx + ); + return doc; + }); + + // TODO(akhilmhdh-pg): licence check, posthog service and snapshot + return deletedSecret; + }; + + const getSecrets = async ({ actorId, path, environment, projectId, actor }: TGetSecretsDTO) => { + const { permission } = await permissionService.getProjectPermission(actor, actorId, projectId); + 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", name: "Create secret" }); + const folderId = folder.id; + + const secrets = await secretDal.findByFolderId(folderId, actorId); + + return secrets; + }; + + const getASecret = async ({ + actorId, + actor, + projectId, + environment, + path, + type, + secretName + }: TGetASecretDTO) => { + const { permission } = await permissionService.getProjectPermission(actor, actorId, projectId); + 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", name: "Create secret" }); + const folderId = folder.id; + + const secretBlindIndex = await generateSecretBlindIndexByName(projectId, secretName); + + const secret = await secretDal.findOne({ + folderId, + type, + userId: type === SecretType.Personal ? actorId : null, + secretBlindIndex + }); + if (!secret) throw new BadRequestError({ message: "Secret not found" }); + + return secret; + }; + + const createManySecret = async ({ + path, + actor, + actorId, + environment, + projectId, + secrets: inputSecrets + }: TCreateBulkSecretDTO) => { + const { permission } = await permissionService.getProjectPermission(actor, actorId, projectId); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Create, + subject(ProjectPermissionSub.Secrets, { environment, secretPath: path }) + ); + + const folder = await folderDal.findBySecretPath(projectId, environment, path); + if (!folder) throw new BadRequestError({ message: "Folder not found", name: "Create secret" }); + const folderId = folder.id; + + const blindIndexDoc = await secretBlindIndexDal.findOne({ projectId }); + if (!blindIndexDoc) + throw new BadRequestError({ message: "Blind index not found", name: "Update secret" }); + + const secretBlindIndexToKey: Record = {}; // used at audit log point + const secretBlindIndexes = await Promise.all( + inputSecrets.map(({ secretName }) => + generateSecretBlindIndexBySalt(secretName, blindIndexDoc) + ) + ).then((blindIndexes) => + blindIndexes.reduce>((prev, curr, i) => { + // eslint-disable-next-line + prev[inputSecrets[i].secretName] = curr; + secretBlindIndexToKey[curr] = inputSecrets[i].secretName; + return prev; + }, {}) + ); + + const exists = await secretDal.findByBlindIndexes( + folderId, + inputSecrets.map(({ type, secretName }) => ({ + blindIndex: secretBlindIndexes[secretName], + type + })) + ); + if (exists.length) throw new BadRequestError({ message: "Secret already exist" }); + + const secrets = await secretDal.transaction(async (tx) => { + const newSecrets = await secretDal.insertMany( + inputSecrets.map(({ secretName, type, ...el }) => ({ + version: 1, + folderId, + type, + secretBlindIndex: secretBlindIndexes[secretName], + ...el, + userId: type === SecretType.Personal ? actorId : null, + algorithm: SecretEncryptionAlgo.AES_256_GCM, + keyEncoding: SecretKeyEncoding.UTF8 + })), + tx + ); + await secretVersionDal.insertMany( + newSecrets.map(({ id, updatedAt, createdAt, ...el }) => ({ + ...el, + secretId: id + })), + tx + ); + + return newSecrets; + }); + return secrets; + }; + + const updateManySecret = async ({ + path, + actor, + actorId, + environment, + projectId, + secrets: inputSecrets + }: TUpdateBulkSecretDTO) => { + const { permission } = await permissionService.getProjectPermission(actor, actorId, projectId); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Create, + subject(ProjectPermissionSub.Secrets, { environment, secretPath: path }) + ); + + const folder = await folderDal.findBySecretPath(projectId, environment, path); + if (!folder) throw new BadRequestError({ message: "Folder not found", name: "Create secret" }); + const folderId = folder.id; + + const blindIndexDoc = await secretBlindIndexDal.findOne({ projectId }); + if (!blindIndexDoc) + throw new BadRequestError({ message: "Blind index not found", name: "Update secret" }); + + // get all blind index + // Find all those secrets + // if not throw not found + const secretBlindIndexToKey: Record = {}; // used at audit log point + const secretBlindIndexes = await Promise.all( + inputSecrets.map(({ secretName }) => + generateSecretBlindIndexBySalt(secretName, blindIndexDoc) + ) + ).then((blindIndexes) => + blindIndexes.reduce>((prev, curr, i) => { + // eslint-disable-next-line + prev[inputSecrets[i].secretName] = curr; + secretBlindIndexToKey[curr] = inputSecrets[i].secretName; + return prev; + }, {}) + ); + + const secretsToBeUpdated = await secretDal.findByBlindIndexes( + folderId, + inputSecrets.map(({ type, secretName }) => ({ + blindIndex: secretBlindIndexes[secretName], + type + })) + ); + if (secretsToBeUpdated.length !== inputSecrets.length) + throw new BadRequestError({ message: "Secret not found" }); + + // now find any secret that needs to update its name + // same process as above + const nameUpdatedSecrets = inputSecrets.filter(({ newSecretName }) => Boolean(newSecretName)); + const newSecretBlindIndexes = await Promise.all( + nameUpdatedSecrets.map(({ newSecretName }) => + generateSecretBlindIndexBySalt(newSecretName as string, blindIndexDoc) + ) + ).then((blindIndexes) => + blindIndexes.reduce>((prev, curr, i) => { + // eslint-disable-next-line + prev[nameUpdatedSecrets[i].secretName] = curr; + return prev; + }, {}) + ); + const secretsWithNewName = await secretDal.findByBlindIndexes( + folderId, + nameUpdatedSecrets.map(({ type, newSecretName }) => ({ + blindIndex: newSecretBlindIndexes[newSecretName as string], + type + })) + ); + if (secretsWithNewName.length) throw new BadRequestError({ message: "Secret not found" }); + + const secretsGroupedByBlindIndex = secretsToBeUpdated.reduce>( + (prev, curr) => { + // eslint-disable-next-line + if (curr.secretBlindIndex) prev[curr.secretBlindIndex] = curr; + return prev; + }, + {} + ); + + const secrets = await secretDal.transaction(async (tx) => { + const newSecrets = await secretDal.bulkUpdate( + inputSecrets.map(({ secretName, type, ...el }) => { + const { version, updatedAt, ...info } = + secretsGroupedByBlindIndex[secretBlindIndexes[secretName]]; + return { + version: (version || 0) + 1, + ...info, + folderId, + type, + secretBlindIndex: + el?.newSecretName && newSecretBlindIndexes[el.newSecretName] + ? newSecretBlindIndexes[el.newSecretName] + : secretBlindIndexes[secretName], + ...el, + userId: type === SecretType.Personal ? actorId : null, + algorithm: SecretEncryptionAlgo.AES_256_GCM, + keyEncoding: SecretKeyEncoding.UTF8 + }; + }), + tx + ); + await secretVersionDal.insertMany( + newSecrets.map(({ id, updatedAt, createdAt, ...el }) => ({ + ...el, + secretId: id + })), + tx + ); + + return newSecrets; + }); + return secrets; + }; + + const deleteManySecret = async ({ + secrets: inputSecrets, + path, + environment, + projectId, + actor, + actorId + }: TDeleteBulkSecretDTO) => { + const { permission } = await permissionService.getProjectPermission(actor, actorId, projectId); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Create, + subject(ProjectPermissionSub.Secrets, { environment, secretPath: path }) + ); + + const folder = await folderDal.findBySecretPath(projectId, environment, path); + if (!folder) throw new BadRequestError({ message: "Folder not found", name: "Create secret" }); + const folderId = folder.id; + + const blindIndexDoc = await secretBlindIndexDal.findOne({ projectId }); + if (!blindIndexDoc) + throw new BadRequestError({ message: "Blind index not found", name: "Update secret" }); + + // get all blind index + // Find all those secrets + // if not throw not found + const secretBlindIndexToKey: Record = {}; // used at audit log point + const secretBlindIndexes = await Promise.all( + inputSecrets.map(({ secretName }) => + generateSecretBlindIndexBySalt(secretName, blindIndexDoc) + ) + ).then((blindIndexes) => + blindIndexes.reduce>((prev, curr, i) => { + // eslint-disable-next-line + prev[inputSecrets[i].secretName] = curr; + secretBlindIndexToKey[curr] = inputSecrets[i].secretName; + return prev; + }, {}) + ); + // not find those secrets. if any of them not found throw an not found error + const secretsToBeDeleted = await secretDal.findByBlindIndexes( + folderId, + inputSecrets.map(({ type, secretName }) => ({ + blindIndex: secretBlindIndexes[secretName], + type + })) + ); + if (secretsToBeDeleted.length !== inputSecrets.length) + throw new BadRequestError({ message: "Secret not found" }); + const secretsDeleted = await secretDal.transaction(async (tx) => + secretDal.deleteMany( + inputSecrets.map(({ type, secretName }) => ({ + blindIndex: secretBlindIndexes[secretName], + type + })), + folderId, + actorId, + tx + ) + ); + + return secretsDeleted; + }; + + return { + createSecret, + deleteSecret, + updateSecret, + createManySecret, + updateManySecret, + deleteManySecret, + getASecret, + getSecrets + }; +}; diff --git a/backend-pg/src/services/secret/secret-types.ts b/backend-pg/src/services/secret/secret-types.ts new file mode 100644 index 000000000..163fef339 --- /dev/null +++ b/backend-pg/src/services/secret/secret-types.ts @@ -0,0 +1,115 @@ +import { SecretType } from "@app/db/schemas"; +import { TProjectPermission } from "@app/lib/types"; + +export type TCreateSecretDTO = { + secretName: string; + path: string; + environment: string; + type: "shared" | "personal"; + secretKeyCiphertext: string; + secretKeyIV: string; + secretKeyTag: string; + secretValueCiphertext: string; + secretValueIV: string; + secretValueTag: string; + secretCommentCiphertext?: string; + secretCommentIV?: string; + secretCommentTag?: string; + skipMultilineEncoding?: boolean; + secretReminderRepeatDays?: number | null; + secretReminderNote?: string | null; + metadata?: { + source?: string; + }; +} & TProjectPermission; + +export type TUpdateSecretDTO = { + secretName: string; + path: string; + newSecretName?: string; + environment: string; + type: "shared" | "personal"; + secretKeyCiphertext?: string; + secretKeyIV?: string; + secretKeyTag?: string; + secretValueCiphertext: string; + secretValueIV: string; + secretValueTag: string; + secretCommentCiphertext?: string; + secretCommentIV?: string; + secretCommentTag?: string; + skipMultilineEncoding?: boolean; + secretReminderRepeatDays?: number | null; + secretReminderNote?: string | null; + metadata?: { + source?: string; + }; +} & TProjectPermission; + +export type TDeleteSecretDTO = { + secretName: string; + secretId?: string; + path: string; + environment: string; + type: "shared" | "personal"; +} & TProjectPermission; + +export type TGetSecretsDTO = { + path: string; + environment: string; +} & TProjectPermission; + +export type TGetASecretDTO = { + secretName: string; + path: string; + environment: string; + type: "shared" | "personal"; +} & TProjectPermission; + +export type TCreateBulkSecretDTO = { + path: string; + environment: string; + secrets: Array<{ + secretName: string; + type: SecretType; + secretKeyCiphertext: string; + secretKeyIV: string; + secretKeyTag: string; + secretValueCiphertext: string; + secretValueIV: string; + secretValueTag: string; + secretCommentCiphertext?: string; + secretCommentIV?: string; + secretCommentTag?: string; + skipMultilineEncoding?: boolean; + metadata?: { + source?: string; + }; + }>; +} & TProjectPermission; + +export type TUpdateBulkSecretDTO = { + path: string; + environment: string; + secrets: Array<{ + type: SecretType; + secretName: string; + newSecretName?: string; + secretValueCiphertext?: string; + secretValueIV?: string; + secretValueTag?: string; + secretCommentCiphertext?: string; + secretCommentIV?: string; + secretCommentTag?: string; + skipMultilineEncoding?: boolean; + }>; +} & TProjectPermission; + +export type TDeleteBulkSecretDTO = { + path: string; + environment: string; + secrets: Array<{ + type: SecretType; + secretName: string; + }>; +} & TProjectPermission; diff --git a/backend-pg/src/services/secret/secret-version-dal.ts b/backend-pg/src/services/secret/secret-version-dal.ts new file mode 100644 index 000000000..3914fa8ae --- /dev/null +++ b/backend-pg/src/services/secret/secret-version-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 TSecretVersionDalFactory = ReturnType; + +export const secretVersionDalFactory = (db: TDbClient) => { + const secretVersionOrm = ormify(db, TableName.SecretVersion); + return secretVersionOrm; +}; diff --git a/frontend/src/hooks/api/roles/queries.tsx b/frontend/src/hooks/api/roles/queries.tsx index 77bb538eb..e735feb59 100644 --- a/frontend/src/hooks/api/roles/queries.tsx +++ b/frontend/src/hooks/api/roles/queries.tsx @@ -14,6 +14,7 @@ import { TGetUserProjectPermissionDTO, TOrgRole, TPermission, + TProjectPermission, TProjectRole } from "./types"; @@ -50,7 +51,7 @@ const getProjectRoles = async (projectId: string) => { }>(`/api/ee/v1/workspace/${projectId}/roles`); return data.data.roles.map(({ permissions, ...el }) => ({ ...el, - permissions: unpackRules(permissions as PackRule[]) + permissions: unpackRules(permissions as PackRule[]) })); }; diff --git a/frontend/src/hooks/api/roles/types.ts b/frontend/src/hooks/api/roles/types.ts index 43dc39e27..0a24389a1 100644 --- a/frontend/src/hooks/api/roles/types.ts +++ b/frontend/src/hooks/api/roles/types.ts @@ -10,7 +10,7 @@ export type TProjectRole = { createdAt: string; updatedAt: string; description?: string; - permissions: TPermission[]; + permissions: TProjectPermission[]; }; export type TOrgRole = { @@ -67,13 +67,13 @@ export type TCreateProjectRoleDTO = { name: string; description?: string; slug: string; - permissions: TPermission[]; + permissions: TProjectPermission[]; }; export type TUpdateProjectRoleDTO = { projectId: string; id: string; -} & Partial>; +} & Partial>; export type TDeleteProjectRoleDTO = { projectId: string; diff --git a/frontend/src/hooks/api/secretFolders/queries.tsx b/frontend/src/hooks/api/secretFolders/queries.tsx index e562e0105..faea30531 100644 --- a/frontend/src/hooks/api/secretFolders/queries.tsx +++ b/frontend/src/hooks/api/secretFolders/queries.tsx @@ -20,25 +20,25 @@ import { } from "./types"; const queryKeys = { - getSecretFolders: ({ workspaceId, environment, directory }: TGetProjectFoldersDTO) => - ["secret-folders", { workspaceId, environment, directory }] as const + getSecretFolders: ({ projectId, environment, path }: TGetProjectFoldersDTO) => + ["secret-folders", { projectId, environment, path }] as const }; -const fetchProjectFolders = async (workspaceId: string, environment: string, directory = "/") => { +const fetchProjectFolders = async (projectId: string, environment: string, path = "/") => { const { data } = await apiRequest.get<{ folders: TSecretFolder[] }>("/api/v1/folders", { params: { - workspaceId, + projectId, environment, - directory + path } }); return data.folders; }; export const useGetProjectFolders = ({ - workspaceId, + projectId, environment, - directory = "/", + path = "/", options = {} }: TGetProjectFoldersDTO & { options?: Omit< @@ -53,21 +53,21 @@ export const useGetProjectFolders = ({ }) => useQuery({ ...options, - queryKey: queryKeys.getSecretFolders({ workspaceId, environment, directory }), - enabled: Boolean(workspaceId) && Boolean(environment) && (options?.enabled ?? true), - queryFn: async () => fetchProjectFolders(workspaceId, environment, directory) + queryKey: queryKeys.getSecretFolders({ projectId, environment, path }), + enabled: Boolean(projectId) && Boolean(environment) && (options?.enabled ?? true), + queryFn: async () => fetchProjectFolders(projectId, environment, path) }); export const useGetFoldersByEnv = ({ - directory = "/", - workspaceId, + path = "/", + projectId, environments }: TGetFoldersByEnvDTO) => { const folders = useQueries({ queries: environments.map((environment) => ({ - queryKey: queryKeys.getSecretFolders({ workspaceId, environment, directory }), - queryFn: async () => fetchProjectFolders(workspaceId, environment, directory), - enabled: Boolean(workspaceId) && Boolean(environment) + queryKey: queryKeys.getSecretFolders({ projectId, environment, path }), + queryFn: async () => fetchProjectFolders(projectId, environment, path), + enabled: Boolean(projectId) && Boolean(environment) })) }); @@ -105,15 +105,13 @@ export const useCreateFolder = () => { const { data } = await apiRequest.post("/api/v1/folders", dto); return data; }, - onSuccess: (_, { workspaceId, environment, directory }) => { + onSuccess: (_, { projectId, environment, path }) => { + queryClient.invalidateQueries(queryKeys.getSecretFolders({ projectId, environment, path })); queryClient.invalidateQueries( - queryKeys.getSecretFolders({ workspaceId, environment, directory }) + secretSnapshotKeys.list({ workspaceId: projectId, environment, directory: path }) ); queryClient.invalidateQueries( - secretSnapshotKeys.list({ workspaceId, environment, directory }) - ); - queryClient.invalidateQueries( - secretSnapshotKeys.count({ workspaceId, environment, directory }) + secretSnapshotKeys.count({ workspaceId: projectId, environment, directory: path }) ); } }); @@ -123,24 +121,22 @@ export const useUpdateFolder = () => { const queryClient = useQueryClient(); return useMutation<{}, {}, TUpdateFolderDTO>({ - mutationFn: async ({ directory = "/", folderName, name, environment, workspaceId }) => { - const { data } = await apiRequest.patch(`/api/v1/folders/${folderName}`, { + mutationFn: async ({ path = "/", folderId, name, environment, projectId }) => { + const { data } = await apiRequest.patch(`/api/v1/folders/${folderId}`, { name, environment, - workspaceId, - directory + projectId, + path }); return data; }, - onSuccess: (_, { workspaceId, environment, directory }) => { + onSuccess: (_, { projectId, environment, path }) => { + queryClient.invalidateQueries(queryKeys.getSecretFolders({ projectId, environment, path })); queryClient.invalidateQueries( - queryKeys.getSecretFolders({ workspaceId, environment, directory }) + secretSnapshotKeys.list({ workspaceId: projectId, environment, directory: path }) ); queryClient.invalidateQueries( - secretSnapshotKeys.list({ workspaceId, environment, directory }) - ); - queryClient.invalidateQueries( - secretSnapshotKeys.count({ workspaceId, environment, directory }) + secretSnapshotKeys.count({ workspaceId: projectId, environment, directory: path }) ); } }); @@ -150,25 +146,23 @@ export const useDeleteFolder = () => { const queryClient = useQueryClient(); return useMutation<{}, {}, TDeleteFolderDTO>({ - mutationFn: async ({ directory = "/", folderName, environment, workspaceId }) => { - const { data } = await apiRequest.delete(`/api/v1/folders/${folderName}`, { + mutationFn: async ({ path = "/", folderId, environment, projectId }) => { + const { data } = await apiRequest.delete(`/api/v1/folders/${folderId}`, { data: { environment, - workspaceId, - directory + projectId, + path } }); return data; }, - onSuccess: (_, { directory = "/", workspaceId, environment }) => { + onSuccess: (_, { path = "/", projectId, environment }) => { + queryClient.invalidateQueries(queryKeys.getSecretFolders({ projectId, environment, path })); queryClient.invalidateQueries( - queryKeys.getSecretFolders({ workspaceId, environment, directory }) + secretSnapshotKeys.list({ workspaceId: projectId, environment, directory: path }) ); queryClient.invalidateQueries( - secretSnapshotKeys.list({ workspaceId, environment, directory }) - ); - queryClient.invalidateQueries( - secretSnapshotKeys.count({ workspaceId, environment, directory }) + secretSnapshotKeys.count({ workspaceId: projectId, environment, directory: path }) ); } }); diff --git a/frontend/src/hooks/api/secretFolders/types.ts b/frontend/src/hooks/api/secretFolders/types.ts index c051173e9..eac202389 100644 --- a/frontend/src/hooks/api/secretFolders/types.ts +++ b/frontend/src/hooks/api/secretFolders/types.ts @@ -4,35 +4,35 @@ export type TSecretFolder = { }; export type TGetProjectFoldersDTO = { - workspaceId: string; + projectId: string; environment: string; - directory?: string; + path?: string; }; export type TGetFoldersByEnvDTO = { environments: string[]; - workspaceId: string; - directory?: string; + projectId: string; + path?: string; }; export type TCreateFolderDTO = { - workspaceId: string; + projectId: string; environment: string; - folderName: string; - directory?: string; + name: string; + path?: string; }; export type TUpdateFolderDTO = { - workspaceId: string; + projectId: string; environment: string; name: string; - folderName: string; - directory?: string; + folderId: string; + path?: string; }; export type TDeleteFolderDTO = { - workspaceId: string; + projectId: string; environment: string; - folderName: string; - directory?: string; + folderId: string; + path?: string; }; diff --git a/frontend/src/hooks/api/secretImports/mutation.tsx b/frontend/src/hooks/api/secretImports/mutation.tsx index 184c8dd63..ccde4dcc0 100644 --- a/frontend/src/hooks/api/secretImports/mutation.tsx +++ b/frontend/src/hooks/api/secretImports/mutation.tsx @@ -9,21 +9,21 @@ export const useCreateSecretImport = () => { const queryClient = useQueryClient(); return useMutation<{}, {}, TCreateSecretImportDTO>({ - mutationFn: async ({ secretImport, environment, workspaceId, directory }) => { + mutationFn: async ({ import: secretImport, environment, projectId, path }) => { const { data } = await apiRequest.post("/api/v1/secret-imports", { - secretImport, + import: secretImport, environment, - workspaceId, - directory + projectId, + path }); return data; }, - onSuccess: (_, { workspaceId, environment, directory }) => { + onSuccess: (_, { environment, projectId, path }) => { queryClient.invalidateQueries( - secretImportKeys.getProjectSecretImports({ workspaceId, environment, directory }) + secretImportKeys.getProjectSecretImports({ projectId, environment, path }) ); queryClient.invalidateQueries( - secretImportKeys.getSecretImportSecrets({ workspaceId, environment, directory }) + secretImportKeys.getSecretImportSecrets({ projectId, environment, path }) ); } }); @@ -33,21 +33,21 @@ export const useUpdateSecretImport = () => { const queryClient = useQueryClient(); return useMutation<{}, {}, TUpdateSecretImportDTO>({ - mutationFn: async ({ environment, workspaceId, directory, secretImports, id }) => { - const { data } = await apiRequest.put(`/api/v1/secret-imports/${id}`, { - secretImports, + mutationFn: async ({ environment, import: secretImports, projectId, path, id }) => { + const { data } = await apiRequest.patch(`/api/v1/secret-imports/${id}`, { + import: secretImports, environment, - workspaceId, - directory + path, + projectId }); return data; }, - onSuccess: (_, { workspaceId, environment, directory }) => { + onSuccess: (_, { environment, projectId, path }) => { queryClient.invalidateQueries( - secretImportKeys.getProjectSecretImports({ workspaceId, environment, directory }) + secretImportKeys.getProjectSecretImports({ projectId, path, environment }) ); queryClient.invalidateQueries( - secretImportKeys.getSecretImportSecrets({ workspaceId, environment, directory }) + secretImportKeys.getSecretImportSecrets({ environment, path, projectId }) ); } }); @@ -57,21 +57,22 @@ export const useDeleteSecretImport = () => { const queryClient = useQueryClient(); return useMutation<{}, {}, TDeleteSecretImportDTO>({ - mutationFn: async ({ id, secretImportEnv, secretImportPath }) => { + mutationFn: async ({ id, projectId, path, environment }) => { const { data } = await apiRequest.delete(`/api/v1/secret-imports/${id}`, { data: { - secretImportPath, - secretImportEnv + projectId, + path, + environment } }); return data; }, - onSuccess: (_, { workspaceId, environment, directory }) => { + onSuccess: (_, { projectId, environment, path }) => { queryClient.invalidateQueries( - secretImportKeys.getProjectSecretImports({ workspaceId, environment, directory }) + secretImportKeys.getProjectSecretImports({ projectId, environment, path }) ); queryClient.invalidateQueries( - secretImportKeys.getSecretImportSecrets({ workspaceId, environment, directory }) + secretImportKeys.getSecretImportSecrets({ projectId, environment, path }) ); } }); diff --git a/frontend/src/hooks/api/secretImports/queries.tsx b/frontend/src/hooks/api/secretImports/queries.tsx index 53c5cbe51..108d012a7 100644 --- a/frontend/src/hooks/api/secretImports/queries.tsx +++ b/frontend/src/hooks/api/secretImports/queries.tsx @@ -7,44 +7,44 @@ import { } from "@app/components/utilities/cryptography/crypto"; import { apiRequest } from "@app/config/request"; -import { TGetImportedSecrets, TGetSecretImports, TImportedSecrets, TSecretImports } from "./types"; +import { TGetImportedSecrets, TGetSecretImports, TImportedSecrets, TSecretImport } from "./types"; export const secretImportKeys = { - getProjectSecretImports: ({ environment, workspaceId, directory }: TGetSecretImports) => - [{ workspaceId, directory, environment }, "secrets-imports"] as const, + getProjectSecretImports: ({ environment, projectId, path }: TGetSecretImports) => + [{ projectId, path, environment }, "secrets-imports"] as const, getSecretImportSecrets: ({ - workspaceId, environment, - directory + projectId, + path }: Omit) => - [{ workspaceId, environment, directory }, "secrets-import-sec"] as const + [{ environment, path, projectId }, "secrets-import-sec"] as const }; -const fetchSecretImport = async ({ workspaceId, environment, directory }: TGetSecretImports) => { - const { data } = await apiRequest.get<{ secretImport: TSecretImports }>( +const fetchSecretImport = async ({ projectId, environment, path = "/" }: TGetSecretImports) => { + const { data } = await apiRequest.get<{ secretImports: TSecretImport[] }>( "/api/v1/secret-imports", { params: { - workspaceId, + projectId, environment, - directory + path } } ); - return data.secretImport; + return data.secretImports; }; export const useGetSecretImports = ({ - workspaceId, environment, - directory = "/", + path = "/", + projectId, options = {} }: TGetSecretImports & { options?: Omit< UseQueryOptions< - TSecretImports, + TSecretImport[], unknown, - TSecretImports, + TSecretImport[], ReturnType >, "queryKey" | "queryFn" @@ -52,9 +52,9 @@ export const useGetSecretImports = ({ }) => useQuery({ ...options, - queryKey: secretImportKeys.getProjectSecretImports({ workspaceId, environment, directory }), - enabled: Boolean(workspaceId) && Boolean(environment) && (options?.enabled ?? true), - queryFn: () => fetchSecretImport({ workspaceId, environment, directory }) + queryKey: secretImportKeys.getProjectSecretImports({ environment, projectId, path }), + enabled: Boolean(projectId) && Boolean(environment) && (options?.enabled ?? true), + queryFn: () => fetchSecretImport({ path, projectId, environment }) }); const fetchImportedSecrets = async ( @@ -76,10 +76,10 @@ const fetchImportedSecrets = async ( }; export const useGetImportedSecrets = ({ - workspaceId, environment, decryptFileKey, - directory, + path, + projectId, options = {} }: TGetImportedSecrets & { options?: Omit< @@ -94,16 +94,16 @@ export const useGetImportedSecrets = ({ }) => useQuery({ enabled: - Boolean(workspaceId) && + Boolean(projectId) && Boolean(environment) && Boolean(decryptFileKey) && (options?.enabled ?? true), queryKey: secretImportKeys.getSecretImportSecrets({ - workspaceId, environment, - directory + path, + projectId }), - queryFn: () => fetchImportedSecrets(workspaceId, environment, directory), + queryFn: () => fetchImportedSecrets(projectId, environment, path), select: useCallback( (data: TImportedSecrets[]) => { const PRIVATE_KEY = localStorage.getItem("PRIVATE_KEY") as string; diff --git a/frontend/src/hooks/api/secretImports/types.ts b/frontend/src/hooks/api/secretImports/types.ts index 173e976e1..b157b880f 100644 --- a/frontend/src/hooks/api/secretImports/types.ts +++ b/frontend/src/hooks/api/secretImports/types.ts @@ -1,12 +1,16 @@ import { UserWsKeyPair } from "../keys/types"; import { EncryptedSecret } from "../secrets/types"; -export type TSecretImports = { +export type TSecretImport = { id: string; - workspaceId: string; - environment: string; folderId: string; - imports: Array<{ environment: string; secretPath: string }>; + importPath: string; + importEnv: { + name: string; + slug: string; + id: string; + }; + position: string; createdAt: string; updatedAt: string; }; @@ -19,44 +23,43 @@ export type TImportedSecrets = { }; export type TGetSecretImports = { - workspaceId: string; + projectId: string; environment: string; - directory?: string; + path?: string; }; export type TGetImportedSecrets = { - workspaceId: string; + projectId: string; environment: string; - directory?: string; + path?: string; decryptFileKey: UserWsKeyPair; }; export type TCreateSecretImportDTO = { - workspaceId: string; + projectId: string; environment: string; - directory?: string; - secretImport: { + path?: string; + import: { environment: string; - secretPath: string; + path: string; }; }; export type TUpdateSecretImportDTO = { id: string; - workspaceId: string; + projectId: string; environment: string; - directory?: string; - secretImports: Array<{ + path?: string; + import: Partial<{ environment: string; - secretPath: string; + path: string; + position: number; }>; }; export type TDeleteSecretImportDTO = { id: string; - workspaceId: string; + projectId: string; environment: string; - directory?: string; - secretImportPath: string; - secretImportEnv: string; + path?: string; }; diff --git a/frontend/src/hooks/api/types.ts b/frontend/src/hooks/api/types.ts index 06c382358..7d514584a 100644 --- a/frontend/src/hooks/api/types.ts +++ b/frontend/src/hooks/api/types.ts @@ -12,7 +12,7 @@ export type { } from "./secretApprovalRequest/types"; export { ApprovalStatus, CommitType } from "./secretApprovalRequest/types"; export type { TSecretFolder } from "./secretFolders/types"; -export type { TImportedSecrets, TSecretImports } from "./secretImports/types"; +export type { TImportedSecrets, TSecretImport } from "./secretImports/types"; export type { TGetSecretRotationProviders, TProviderTemplate, diff --git a/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleList/ProjectRoleList.tsx b/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleList/ProjectRoleList.tsx index ac4038711..a1f8c6b21 100644 --- a/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleList/ProjectRoleList.tsx +++ b/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleList/ProjectRoleList.tsx @@ -89,7 +89,7 @@ export const ProjectRoleList = ({ onSelectRole }: Props) => { {isRolesLoading && } - {(roles as TProjectRole[])?.map((role) => { + {roles?.map((role) => { const { id, name, slug } = role; const isNonMutatable = ["admin", "member", "viewer", "no-access"].includes(slug); diff --git a/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleModifySection/ProjectRoleModifySection.utils.ts b/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleModifySection/ProjectRoleModifySection.utils.ts index 472d966bb..35c41ded0 100644 --- a/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleModifySection/ProjectRoleModifySection.utils.ts +++ b/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleModifySection/ProjectRoleModifySection.utils.ts @@ -118,7 +118,7 @@ const multiEnvForm2Api = ( const isFullAccess = PERMISSION_ACTIONS.every((action) => formVal?.all?.[action]); // if any of them is set in all push it without any condition PERMISSION_ACTIONS.forEach((action) => { - if (formVal?.all?.[action]) permissions.push({ action, subject }); + if (formVal?.all?.[action]) permissions.push({ action, subject: [subject] }); }); if (!isFullAccess) { @@ -139,7 +139,7 @@ const multiEnvForm2Api = ( if (formVal[slug]?.secretPath) conditions.secretPath = { $glob: formVal?.[slug]?.secretPath }; - permissions.push({ action, subject, conditions }); + permissions.push({ action, subject: [subject], conditions }); } }); }); @@ -156,7 +156,7 @@ export const formRolePermission2API = (formVal: TFormSchema["permissions"]) => { } else { Object.entries(actions).forEach(([action, isAllowed]) => { if (isAllowed) { - permissions.push({ subject: rule, action }); + permissions.push({ subject: [rule], action }); } }); } diff --git a/frontend/src/views/SecretMainPage/SecretMainPage.tsx b/frontend/src/views/SecretMainPage/SecretMainPage.tsx index 2c85ba916..d3c1a5b93 100644 --- a/frontend/src/views/SecretMainPage/SecretMainPage.tsx +++ b/frontend/src/views/SecretMainPage/SecretMainPage.tsx @@ -88,9 +88,9 @@ export const SecretMainPage = () => { }); // fetch folders const { data: folders, isLoading: isFoldersLoading } = useGetProjectFolders({ - workspaceId, + projectId: workspaceId, environment, - directory: secretPath + path: secretPath }); // fetch secret imports const { @@ -98,9 +98,9 @@ export const SecretMainPage = () => { isLoading: isSecretImportsLoading, isFetching: isSecretImportsFetching } = useGetSecretImports({ - workspaceId, + projectId: workspaceId, environment, - directory: secretPath, + path: secretPath, options: { enabled: canReadSecret } @@ -108,15 +108,15 @@ export const SecretMainPage = () => { // fetch imported secrets to show user the overriden ones const { data: importedSecrets } = useGetImportedSecrets({ - workspaceId, + projectId: workspaceId, environment, decryptFileKey: decryptFileKey!, - directory: secretPath, + path: secretPath, options: { enabled: canReadSecret } }); - // fetch tags + // fech tags const { data: tags } = useGetWsTags(canReadSecret ? workspaceId : ""); const { data: boardPolicy } = useGetSecretApprovalPolicyOfABoard({ @@ -146,7 +146,7 @@ export const SecretMainPage = () => { isPaused: !canDoReadRollback }); - const isNotEmtpy = Boolean(secrets?.length || folders?.length || secretImports?.imports?.length); + const isNotEmtpy = Boolean(secrets?.length || folders?.length || secretImports?.length); const handleSortToggle = () => setSortDir((state) => (state === SortDir.ASC ? SortDir.DESC : SortDir.ASC)); diff --git a/frontend/src/views/SecretMainPage/components/ActionBar/ActionBar.tsx b/frontend/src/views/SecretMainPage/components/ActionBar/ActionBar.tsx index bb75d496b..94afeb4b2 100644 --- a/frontend/src/views/SecretMainPage/components/ActionBar/ActionBar.tsx +++ b/frontend/src/views/SecretMainPage/components/ActionBar/ActionBar.tsx @@ -112,10 +112,10 @@ export const ActionBar = ({ const handleFolderCreate = async (folderName: string) => { try { await createFolder({ - folderName, - directory: secretPath, + name: folderName, + path: secretPath, environment, - workspaceId + projectId: workspaceId }); handlePopUpClose("addFolder"); createNotification({ diff --git a/frontend/src/views/SecretMainPage/components/ActionBar/CreateSecretImportForm.tsx b/frontend/src/views/SecretMainPage/components/ActionBar/CreateSecretImportForm.tsx index 9d8a3c9b0..8a97a799f 100644 --- a/frontend/src/views/SecretMainPage/components/ActionBar/CreateSecretImportForm.tsx +++ b/frontend/src/views/SecretMainPage/components/ActionBar/CreateSecretImportForm.tsx @@ -66,11 +66,11 @@ export const CreateSecretImportForm = ({ try { await createSecretImport({ environment, - workspaceId, - directory: secretPath, - secretImport: { + projectId: workspaceId, + path: secretPath, + import: { environment: importedEnv, - secretPath: importedSecPath + path: importedSecPath } }); onClose(); diff --git a/frontend/src/views/SecretMainPage/components/FolderListView/FolderListView.tsx b/frontend/src/views/SecretMainPage/components/FolderListView/FolderListView.tsx index 2f86f774d..6fa9132f6 100644 --- a/frontend/src/views/SecretMainPage/components/FolderListView/FolderListView.tsx +++ b/frontend/src/views/SecretMainPage/components/FolderListView/FolderListView.tsx @@ -42,12 +42,13 @@ export const FolderListView = ({ const handleFolderUpdate = async (newFolderName: string) => { try { + const { id: folderId } = popUp.updateFolder.data as TSecretFolder; await updateFolder({ - folderName: popUp.updateFolder.data as string, + folderId, name: newFolderName, - directory: secretPath, + path: secretPath, environment, - workspaceId + projectId: workspaceId }); handlePopUpClose("updateFolder"); createNotification({ @@ -65,11 +66,12 @@ export const FolderListView = ({ const handleFolderDelete = async () => { try { + const { id: folderId } = popUp.deleteFolder.data as TSecretFolder; await deleteFolder({ - folderName: popUp.deleteFolder.data as string, - directory: secretPath, + folderId, + path: secretPath, environment, - workspaceId + projectId: workspaceId }); handlePopUpClose("deleteFolder"); createNotification({ @@ -106,13 +108,13 @@ export const FolderListView = ({ .map(({ name, id }) => (
-
+
{ @@ -122,7 +124,7 @@ export const FolderListView = ({ > {name}
-
+
handlePopUpOpen("updateFolder", name)} + className="p-0 opacity-0 group-hover:opacity-100" + onClick={() => handlePopUpOpen("updateFolder", { id, name })} isDisabled={!isAllowed} > @@ -153,8 +155,8 @@ export const FolderListView = ({ ariaLabel="delete-folder" variant="plain" size="md" - className="group-hover:opacity-100 opacity-0 p-0" - onClick={() => handlePopUpOpen("deleteFolder", name)} + className="p-0 opacity-0 group-hover:opacity-100" + onClick={() => handlePopUpOpen("deleteFolder", { id, name })} isDisabled={!isAllowed} > @@ -171,14 +173,14 @@ export const FolderListView = ({ handlePopUpToggle("deleteFolder", isOpen)} onDeleteApproved={handleFolderDelete} diff --git a/frontend/src/views/SecretMainPage/components/SecretImportListView/SecretImportItem.tsx b/frontend/src/views/SecretMainPage/components/SecretImportListView/SecretImportItem.tsx index cbd4c3f84..dd0f5fbec 100644 --- a/frontend/src/views/SecretMainPage/components/SecretImportListView/SecretImportItem.tsx +++ b/frontend/src/views/SecretMainPage/components/SecretImportListView/SecretImportItem.tsx @@ -12,17 +12,18 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { ProjectPermissionCan } from "@app/components/permissions"; import { EmptyState, IconButton, SecretInput, TableContainer } from "@app/components/v2"; -import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context"; +import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context"; import { useToggle } from "@app/hooks"; type Props = { - onDelete: (environment: string, secretPath: string) => void; + onDelete: () => void; environment: string; secretPath?: string; - importedEnv: string; - importedSecPath: string; + importEnvName: string; + importEnvPath: string; importedSecrets: { key: string; value: string; overriden: { env: string; secretPath: string } }[]; searchTerm: string; + id: string; }; // to show the environment and folder icon @@ -31,7 +32,7 @@ export const EnvFolderIcon = ({ env, secretPath }: { env: string; secretPath: st
{env || "-"}
{secretPath && (
- + {secretPath}
)} @@ -39,9 +40,10 @@ export const EnvFolderIcon = ({ env, secretPath }: { env: string; secretPath: st ); export const SecretImportItem = ({ - importedEnv, - importedSecPath, onDelete, + id, + importEnvName, + importEnvPath, importedSecrets = [], searchTerm = "", secretPath, @@ -49,10 +51,8 @@ export const SecretImportItem = ({ }: Props) => { const [isExpanded, setIsExpanded] = useToggle(); const { attributes, listeners, transform, transition, setNodeRef, isDragging } = useSortable({ - id: `${importedEnv}-${importedSecPath}` + id }); - const { currentWorkspace } = useWorkspace(); - const rowEnv = currentWorkspace?.environments?.find(({ slug }) => slug === importedEnv); useEffect(() => { const filteredSecrets = importedSecrets.filter((secret) => @@ -80,7 +80,7 @@ export const SecretImportItem = ({ return ( <>
setIsExpanded.toggle()} onKeyDown={() => setIsExpanded.toggle()} > -
+
-
- +
+
-
+
{ evt.stopPropagation(); - onDelete(importedEnv, importedSecPath); + onDelete(); }} isDisabled={!isAllowed} > @@ -167,7 +167,7 @@ export const SecretImportItem = ({ {importedSecrets .filter((secret) => secret.key.toUpperCase().includes(searchTerm.toUpperCase())) .map(({ key, value, overriden }, index) => ( - + {key} diff --git a/frontend/src/views/SecretMainPage/components/SecretImportListView/SecretImportListView.tsx b/frontend/src/views/SecretMainPage/components/SecretImportListView/SecretImportListView.tsx index 6b216b8f8..136de9406 100644 --- a/frontend/src/views/SecretMainPage/components/SecretImportListView/SecretImportListView.tsx +++ b/frontend/src/views/SecretMainPage/components/SecretImportListView/SecretImportListView.tsx @@ -17,7 +17,7 @@ import { DeleteActionModal } from "@app/components/v2"; import { useWorkspace } from "@app/context"; import { usePopUp } from "@app/hooks"; import { useDeleteSecretImport, useUpdateSecretImport } from "@app/hooks/api"; -import { TSecretImports } from "@app/hooks/api/secretImports/types"; +import { TSecretImport } from "@app/hooks/api/secretImports/types"; import { DecryptedSecret } from "@app/hooks/api/types"; import { SecretImportItem } from "./SecretImportItem"; @@ -76,17 +76,15 @@ type Props = { environment: string; workspaceId: string; secretPath?: string; - secretImports?: TSecretImports; + secretImports?: TSecretImport[]; isFetching?: boolean; secrets?: DecryptedSecret[]; importedSecrets?: TImportedSecrets; searchTerm: string; }; -type TDeleteSecretImport = { environment: string; secretPath: string }; - export const SecretImportListView = ({ - secretImports, + secretImports = [], environment, workspaceId, secretPath, @@ -107,21 +105,11 @@ export const SecretImportListView = ({ useSensor(KeyboardSensor, {}) ); - const [items, setItems] = useState( - (secretImports?.imports || [])?.map((dto) => ({ - id: `${dto.environment}-${dto.secretPath}`, - ...dto - })) - ); + const [items, setItems] = useState(secretImports); useEffect(() => { if (!isFetching) { - setItems( - (secretImports?.imports || [])?.map((dto) => ({ - id: `${dto.environment}-${dto.secretPath}`, - ...dto - })) - ); + setItems(secretImports); } }, [isFetching]); @@ -129,24 +117,19 @@ export const SecretImportListView = ({ const { mutate: updateSecretImport } = useUpdateSecretImport(); const handleSecretImportDelete = async () => { - const { environment: importEnv, secretPath: impSecPath } = popUp.deleteSecretImport - ?.data as TDeleteSecretImport; + const { id: secretImportId } = popUp.deleteSecretImport?.data as { id: string }; try { - if (secretImports?.id) { - await deleteSecretImport({ - workspaceId, - environment, - directory: secretPath, - id: secretImports?.id, - secretImportEnv: importEnv, - secretImportPath: impSecPath - }); - handlePopUpClose("deleteSecretImport"); - createNotification({ - type: "success", - text: "Successfully removed secret link" - }); - } + await deleteSecretImport({ + projectId: workspaceId, + environment, + path: secretPath, + id: secretImportId + }); + handlePopUpClose("deleteSecretImport"); + createNotification({ + type: "success", + text: "Successfully removed secret link" + }); } catch (err) { console.error(err); createNotification({ @@ -163,11 +146,13 @@ export const SecretImportListView = ({ const newImportOrder = arrayMove(items, oldIndex, newIndex); setItems(newImportOrder); updateSecretImport({ - workspaceId, + projectId: workspaceId, environment, - directory: secretPath, - id: secretImports?.id || "", - secretImports: newImportOrder + path: secretPath, + id: active.id as string, + import: { + position: newIndex + 1 + } }); } }; @@ -181,26 +166,28 @@ export const SecretImportListView = ({ modifiers={[restrictToVerticalAxis]} > - {items?.map(({ secretPath: importedSecPath, environment: importedEnv }) => ( - - handlePopUpOpen("deleteSecretImport", { environment: env, secretPath: secPath }) - } - /> - ))} + {items?.map((item) => { + const { importPath, importEnv, id } = item; + return ( + handlePopUpOpen("deleteSecretImport", item)} + /> + ); + })} handlePopUpToggle("deleteSecretImport", isOpen)} onDeleteApproved={handleSecretImportDelete} /> diff --git a/frontend/src/views/SecretMainPage/components/SecretListView/SecretListView.tsx b/frontend/src/views/SecretMainPage/components/SecretListView/SecretListView.tsx index 5054fbad4..780e49448 100644 --- a/frontend/src/views/SecretMainPage/components/SecretListView/SecretListView.tsx +++ b/frontend/src/views/SecretMainPage/components/SecretListView/SecretListView.tsx @@ -208,8 +208,8 @@ export const SecretListView = ({ } = modSecret; const hasKeyChanged = oldKey !== key; - const tagIds = tags.map(({ id }) => id); - const oldTagIds = orgSecret.tags.map(({ id }) => id); + const tagIds = tags?.map(({ id }) => id); + const oldTagIds = (orgSecret?.tags || []).map(({ id }) => id); const isSameTags = JSON.stringify(tagIds) === JSON.stringify(oldTagIds); const isSharedSecUnchanged = ( diff --git a/frontend/src/views/SecretOverviewPage/SecretOverviewPage.tsx b/frontend/src/views/SecretOverviewPage/SecretOverviewPage.tsx index 99c167890..68a6a0b70 100644 --- a/frontend/src/views/SecretOverviewPage/SecretOverviewPage.tsx +++ b/frontend/src/views/SecretOverviewPage/SecretOverviewPage.tsx @@ -99,9 +99,9 @@ export const SecretOverviewPage = () => { decryptFileKey: latestFileKey! }); const { folders, folderNames, isFolderPresentInEnv } = useGetFoldersByEnv({ - workspaceId, - environments: userAvailableEnvs.map(({ slug }) => slug), - directory: secretPath + projectId: workspaceId, + path: secretPath, + environments: userAvailableEnvs.map(({ slug }) => slug) }); const { mutateAsync: createSecretV3 } = useCreateSecretV3(); @@ -119,10 +119,10 @@ export const SecretOverviewPage = () => { const folderName = pathSegment.at(-1); if (folderName && parentPath) { await createFolder({ - workspaceId, + projectId: workspaceId, + path: secretPath, environment: env, - directory: parentPath, - folderName + name: folderName }); } } @@ -216,10 +216,10 @@ export const SecretOverviewPage = () => { const folderName = path.at(-1); if (folderName && directory) { await createFolder({ - workspaceId, + projectId: workspaceId, environment: slug, - directory, - folderName + path: secretPath, + name: folderName }); } }