diff --git a/backend-pg/package-lock.json b/backend-pg/package-lock.json index d64856c04..804392f7b 100644 --- a/backend-pg/package-lock.json +++ b/backend-pg/package-lock.json @@ -32,6 +32,7 @@ "jsonwebtoken": "^9.0.2", "jsrp": "^0.2.4", "knex": "^3.0.1", + "nanoid": "^5.0.4", "nodemailer": "^6.9.7", "ora": "^7.0.1", "passport-github": "^1.1.0", @@ -5959,10 +5960,9 @@ } }, "node_modules/nanoid": { - "version": "3.3.7", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz", - "integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==", - "dev": true, + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.0.4.tgz", + "integrity": "sha512-vAjmBf13gsmhXSgBrtIclinISzFFy22WwCYoyilZlsrRXNIHSwgFQ1bEdjRwMT3aoadeIF6HMuDRlOxzfXV8ig==", "funding": [ { "type": "github", @@ -5970,10 +5970,10 @@ } ], "bin": { - "nanoid": "bin/nanoid.cjs" + "nanoid": "bin/nanoid.js" }, "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + "node": "^18 || >=20" } }, "node_modules/natural-compare": { @@ -6772,6 +6772,24 @@ } } }, + "node_modules/postcss/node_modules/nanoid": { + "version": "3.3.7", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz", + "integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, "node_modules/postgres-array": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", diff --git a/backend-pg/package.json b/backend-pg/package.json index 62434fd11..811ec61d3 100644 --- a/backend-pg/package.json +++ b/backend-pg/package.json @@ -85,6 +85,7 @@ "jsonwebtoken": "^9.0.2", "jsrp": "^0.2.4", "knex": "^3.0.1", + "nanoid": "^5.0.4", "nodemailer": "^6.9.7", "ora": "^7.0.1", "passport-github": "^1.1.0", diff --git a/backend-pg/src/@types/fastify.d.ts b/backend-pg/src/@types/fastify.d.ts index 1060d16fc..f1dabfca1 100644 --- a/backend-pg/src/@types/fastify.d.ts +++ b/backend-pg/src/@types/fastify.d.ts @@ -23,6 +23,7 @@ import { TProjectKeyServiceFactory } from "@app/services/project-key/project-key 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 { TSecretApprovalPolicyServiceFactory } from "@app/services/secret-approval-policy/secret-approval-policy-service"; import { TSecretFolderServiceFactory } from "@app/services/secret-folder/secret-folder-service"; import { TSecretImportServiceFactory } from "@app/services/secret-import/secret-import-service"; import { TSecretTagServiceFactory } from "@app/services/secret-tag/secret-tag-service"; @@ -90,6 +91,7 @@ declare module "fastify" { identityAccessToken: TIdentityAccessTokenServiceFactory; identityProject: TIdentityProjectServiceFactory; identityUa: TIdentityUaServiceFactory; + secretApprovalPolicy: TSecretApprovalPolicyServiceFactory; }; // 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 e1fcd6dae..8d6550bbb 100644 --- a/backend-pg/src/@types/knex.d.ts +++ b/backend-pg/src/@types/knex.d.ts @@ -67,6 +67,24 @@ import { TProjects, TProjectsInsert, TProjectsUpdate, + TSapApprovers, + TSapApproversInsert, + TSapApproversUpdate, + TSaRequestSecrets, + TSaRequestSecretsInsert, + TSaRequestSecretsUpdate, + TSaRequestSecretTags, + TSaRequestSecretTagsInsert, + TSaRequestSecretTagsUpdate, + TSarReviewers, + TSarReviewersInsert, + TSarReviewersUpdate, + TSecretApprovalPolicies, + TSecretApprovalPoliciesInsert, + TSecretApprovalPoliciesUpdate, + TSecretApprovalRequests, + TSecretApprovalRequestsInsert, + TSecretApprovalRequestsUpdate, TSecretBlindIndexes, TSecretBlindIndexesInsert, TSecretBlindIndexesUpdate, @@ -256,6 +274,36 @@ declare module "knex/types/tables" { TIdentityProjectMembershipsInsert, TIdentityProjectMembershipsUpdate >; + [TableName.SecretApprovalPolicy]: Knex.CompositeTableType< + TSecretApprovalPolicies, + TSecretApprovalPoliciesInsert, + TSecretApprovalPoliciesUpdate + >; + [TableName.SapApprover]: Knex.CompositeTableType< + TSapApprovers, + TSapApproversInsert, + TSapApproversUpdate + >; + [TableName.SecretApprovalRequest]: Knex.CompositeTableType< + TSecretApprovalRequests, + TSecretApprovalRequestsInsert, + TSecretApprovalRequestsUpdate + >; + [TableName.SarReviewer]: Knex.CompositeTableType< + TSarReviewers, + TSarReviewersInsert, + TSarReviewersUpdate + >; + [TableName.SarSecret]: Knex.CompositeTableType< + TSaRequestSecrets, + TSaRequestSecretsInsert, + TSaRequestSecretsUpdate + >; + [TableName.SarSecretTag]: Knex.CompositeTableType< + TSaRequestSecretTags, + TSaRequestSecretTagsInsert, + TSaRequestSecretTagsUpdate + >; // Junction tables [TableName.JnSecretTag]: Knex.CompositeTableType< TSecretTagJunction, diff --git a/backend-pg/src/db/migrations/20240101054849_secret-approval-policy.ts b/backend-pg/src/db/migrations/20240101054849_secret-approval-policy.ts new file mode 100644 index 000000000..ac2e01290 --- /dev/null +++ b/backend-pg/src/db/migrations/20240101054849_secret-approval-policy.ts @@ -0,0 +1,44 @@ +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.SecretApprovalPolicy))) { + await knex.schema.createTable(TableName.SecretApprovalPolicy, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + t.string("name").notNullable(); + t.string("secretPath"); + t.integer("approvals").defaultTo(1).notNullable(); + t.uuid("envId").notNullable(); + t.foreign("envId").references("id").inTable(TableName.Environment).onDelete("CASCADE"); + t.timestamps(true, true, true); + }); + } + await createOnUpdateTrigger(knex, TableName.SecretApprovalPolicy); + + if (!(await knex.schema.hasTable(TableName.SapApprover))) { + await knex.schema.createTable(TableName.SapApprover, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + t.uuid("approverId").notNullable(); + t.foreign("approverId") + .references("id") + .inTable(TableName.ProjectMembership) + .onDelete("CASCADE"); + t.uuid("policyId").notNullable(); + t.foreign("policyId") + .references("id") + .inTable(TableName.SecretApprovalPolicy) + .onDelete("CASCADE"); + t.timestamps(true, true, true); + }); + } + + await createOnUpdateTrigger(knex, TableName.SapApprover); +} + +export async function down(knex: Knex): Promise { + await knex.schema.dropTableIfExists(TableName.SapApprover); + await knex.schema.dropTableIfExists(TableName.SecretApprovalPolicy); + await dropOnUpdateTrigger(knex, TableName.SapApprover); + await dropOnUpdateTrigger(knex, TableName.SecretApprovalPolicy); +} diff --git a/backend-pg/src/db/migrations/20240101104907_secret-approval-request.ts b/backend-pg/src/db/migrations/20240101104907_secret-approval-request.ts new file mode 100644 index 000000000..5f6981ecf --- /dev/null +++ b/backend-pg/src/db/migrations/20240101104907_secret-approval-request.ts @@ -0,0 +1,114 @@ +import { Knex } from "knex"; +import { SecretEncryptionAlgo, SecretKeyEncoding, TableName } from "../schemas"; +import { createOnUpdateTrigger, dropOnUpdateTrigger } from "../utils"; + +export async function up(knex: Knex): Promise { + if (!(await knex.schema.hasTable(TableName.SecretApprovalRequest))) { + await knex.schema.createTable(TableName.SecretApprovalRequest, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + t.uuid("policyId").notNullable(); + t.boolean("hasMerged").defaultTo(false).notNullable(); + t.string("status").defaultTo("open").notNullable(); + t.jsonb("conflicts"); + t.foreign("policyId") + .references("id") + .inTable(TableName.SecretApprovalPolicy) + .onDelete("CASCADE"); + t.string("slug").notNullable(); + t.uuid("folderId").notNullable(); + t.foreign("folderId").references("id").inTable(TableName.SecretFolder).onDelete("CASCADE"); + t.uuid("statusChangeBy"); + t.foreign("statusChangeBy") + .references("id") + .inTable(TableName.ProjectMembership) + .onDelete("CASCADE"); + t.uuid("committerId").notNullable(); + t.foreign("committerId") + .references("id") + .inTable(TableName.ProjectMembership) + .onDelete("CASCADE"); + t.timestamps(true, true, true); + }); + } + await createOnUpdateTrigger(knex, TableName.SecretApprovalRequest); + + if (!(await knex.schema.hasTable(TableName.SarReviewer))) { + await knex.schema.createTable(TableName.SarReviewer, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + t.uuid("member").notNullable(); + t.foreign("member").references("id").inTable(TableName.ProjectMembership).onDelete("CASCADE"); + t.string("status").notNullable(); + t.uuid("requestId").notNullable(); + t.foreign("requestId") + .references("id") + .inTable(TableName.SecretApprovalRequest) + .onDelete("CASCADE"); + t.timestamps(true, true, true); + }); + } + await createOnUpdateTrigger(knex, TableName.SarReviewer); + + if (!(await knex.schema.hasTable(TableName.SarSecret))) { + await knex.schema.createTable(TableName.SarSecret, (t) => { + // everything related to secret + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + t.integer("version").defaultTo(1); + 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.timestamps(true, true, true); + // commit details + t.uuid("requestId").notNullable(); + t.foreign("requestId") + .references("id") + .inTable(TableName.SecretApprovalRequest) + .onDelete("CASCADE"); + t.string("op").notNullable(); + t.uuid("secretId"); + t.foreign("secretId").references("id").inTable(TableName.Secret).onDelete("SET NULL"); + t.uuid("secretVersion"); + t.foreign("secretVersion") + .references("id") + .inTable(TableName.SecretVersion) + .onDelete("SET NULL"); + }); + } + await createOnUpdateTrigger(knex, TableName.SarSecret); + + if (!(await knex.schema.hasTable(TableName.SarSecretTag))) { + await knex.schema.createTable(TableName.SarSecretTag, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + t.uuid("secretId").notNullable(); + t.foreign("secretId").references("id").inTable(TableName.SarSecret).onDelete("CASCADE"); + t.uuid("tagId").notNullable(); + t.foreign("tagId").references("id").inTable(TableName.SecretTag).onDelete("CASCADE"); + t.timestamps(true, true, true); + }); + } + await createOnUpdateTrigger(knex, TableName.SarSecretTag); +} + +export async function down(knex: Knex): Promise { + await knex.schema.dropTableIfExists(TableName.SecretTag); + await knex.schema.dropTableIfExists(TableName.SarSecret); + await knex.schema.dropTableIfExists(TableName.SarReviewer); + await knex.schema.dropTableIfExists(TableName.SecretApprovalRequest); + + await dropOnUpdateTrigger(knex, TableName.SarSecretTag); + await dropOnUpdateTrigger(knex, TableName.SarSecret); + await dropOnUpdateTrigger(knex, TableName.SarReviewer); + await dropOnUpdateTrigger(knex, TableName.SecretApprovalRequest); +} diff --git a/backend-pg/src/db/schemas/index.ts b/backend-pg/src/db/schemas/index.ts index b47e18c54..78b1cd7bd 100644 --- a/backend-pg/src/db/schemas/index.ts +++ b/backend-pg/src/db/schemas/index.ts @@ -21,6 +21,12 @@ export * from "./project-keys"; export * from "./project-memberships"; export * from "./project-roles"; export * from "./projects"; +export * from "./sa-request-secret-tags"; +export * from "./sa-request-secrets"; +export * from "./sap-approvers"; +export * from "./sar-reviewers"; +export * from "./secret-approval-policies"; +export * from "./secret-approval-requests"; export * from "./secret-blind-indexes"; export * from "./secret-folders"; export * from "./secret-imports"; diff --git a/backend-pg/src/db/schemas/models.ts b/backend-pg/src/db/schemas/models.ts index 52fb7b08c..133fac1ca 100644 --- a/backend-pg/src/db/schemas/models.ts +++ b/backend-pg/src/db/schemas/models.ts @@ -34,7 +34,14 @@ export enum TableName { IdentityUniversalAuth = "identity_universal_auths", IdentityUaClientSecret = "identity_ua_client_secrets", IdentityOrgMembership = "identity_org_memberships", - IdentityProjectMembership = " identity_project_memberships", + IdentityProjectMembership = "identity_project_memberships", + SecretApprovalPolicy = "secret_approval_policies", + SapApprover = "sap_approvers", // sap: secret approval policy + SecretApprovalRequest = "secret_approval_requests", + SarReviewer = "sar_reviewers", + SarSecret = "sa_request_secrets", + SarSecretTag = "sa_request_secret_tags", + // junction tables JnSecretTag = "secret_tag_junction", JnSecretVersionTag = "secret_version_tag_junction" } diff --git a/backend-pg/src/db/schemas/sa-request-secret-tags.ts b/backend-pg/src/db/schemas/sa-request-secret-tags.ts new file mode 100644 index 000000000..d0366f125 --- /dev/null +++ b/backend-pg/src/db/schemas/sa-request-secret-tags.ts @@ -0,0 +1,20 @@ +// 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 SaRequestSecretTagsSchema = z.object({ + id: z.string().uuid(), + secretId: z.string().uuid(), + tagId: z.string().uuid(), + createdAt: z.date(), + updatedAt: z.date(), +}); + +export type TSaRequestSecretTags = z.infer; +export type TSaRequestSecretTagsInsert = Omit; +export type TSaRequestSecretTagsUpdate = Partial>; diff --git a/backend-pg/src/db/schemas/sa-request-secrets.ts b/backend-pg/src/db/schemas/sa-request-secrets.ts new file mode 100644 index 000000000..80f2376fb --- /dev/null +++ b/backend-pg/src/db/schemas/sa-request-secrets.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 SaRequestSecretsSchema = z.object({ + id: z.string().uuid(), + version: z.number().default(1).nullable().optional(), + 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(), + createdAt: z.date(), + updatedAt: z.date(), + requestId: z.string().uuid(), + op: z.string(), + secretId: z.string().uuid().nullable().optional(), + secretVersion: z.string().uuid().nullable().optional(), +}); + +export type TSaRequestSecrets = z.infer; +export type TSaRequestSecretsInsert = Omit; +export type TSaRequestSecretsUpdate = Partial>; diff --git a/backend-pg/src/db/schemas/sap-approvers.ts b/backend-pg/src/db/schemas/sap-approvers.ts new file mode 100644 index 000000000..65cc0aea0 --- /dev/null +++ b/backend-pg/src/db/schemas/sap-approvers.ts @@ -0,0 +1,20 @@ +// 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 SapApproversSchema = z.object({ + id: z.string().uuid(), + approverId: z.string().uuid(), + policyId: z.string().uuid(), + createdAt: z.date(), + updatedAt: z.date(), +}); + +export type TSapApprovers = z.infer; +export type TSapApproversInsert = Omit; +export type TSapApproversUpdate = Partial>; diff --git a/backend-pg/src/db/schemas/sar-reviewers.ts b/backend-pg/src/db/schemas/sar-reviewers.ts new file mode 100644 index 000000000..a23d5cd75 --- /dev/null +++ b/backend-pg/src/db/schemas/sar-reviewers.ts @@ -0,0 +1,21 @@ +// 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 SarReviewersSchema = z.object({ + id: z.string().uuid(), + member: z.string().uuid(), + status: z.string(), + requestId: z.string().uuid(), + createdAt: z.date(), + updatedAt: z.date(), +}); + +export type TSarReviewers = z.infer; +export type TSarReviewersInsert = Omit; +export type TSarReviewersUpdate = Partial>; diff --git a/backend-pg/src/db/schemas/secret-approval-policies.ts b/backend-pg/src/db/schemas/secret-approval-policies.ts new file mode 100644 index 000000000..ec859bb4e --- /dev/null +++ b/backend-pg/src/db/schemas/secret-approval-policies.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 SecretApprovalPoliciesSchema = z.object({ + id: z.string().uuid(), + name: z.string(), + secretPath: z.string().nullable().optional(), + approvals: z.number().default(1), + envId: z.string().uuid(), + createdAt: z.date(), + updatedAt: z.date(), +}); + +export type TSecretApprovalPolicies = z.infer; +export type TSecretApprovalPoliciesInsert = Omit; +export type TSecretApprovalPoliciesUpdate = Partial>; diff --git a/backend-pg/src/db/schemas/secret-approval-requests.ts b/backend-pg/src/db/schemas/secret-approval-requests.ts new file mode 100644 index 000000000..32f390ba7 --- /dev/null +++ b/backend-pg/src/db/schemas/secret-approval-requests.ts @@ -0,0 +1,28 @@ +// 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 SecretApprovalRequestsSchema = z.object({ + id: z.string().uuid(), + policyId: z.string().uuid(), + hasMerged: z.boolean().default(false), + status: z.string().default("open"), + conflicts: z.unknown().nullable().optional(), + slug: z.string(), + folderId: z.string().uuid(), + statusChangeBy: z.string().uuid().optional().nullable(), + committerId: z.string().uuid(), + createdAt: z.date(), + updatedAt: z.date() +}); + +export type TSecretApprovalRequests = z.infer; +export type TSecretApprovalRequestsInsert = Omit; +export type TSecretApprovalRequestsUpdate = Partial< + Omit +>; diff --git a/backend-pg/src/ee/routes/v1/index.ts b/backend-pg/src/ee/routes/v1/index.ts index d4477f9fd..851e42614 100644 --- a/backend-pg/src/ee/routes/v1/index.ts +++ b/backend-pg/src/ee/routes/v1/index.ts @@ -1,8 +1,10 @@ import { registerOrgRoleRouter } from "./org-role-router"; import { registerProjectRoleRouter } from "./project-role-router"; +import { registerSecretApprovalPolicyRouter } from "./secret-approval-policy-router"; export const registerV1EERoutes = async (server: FastifyZodProvider) => { // org role starts with organization await server.register(registerOrgRoleRouter, { prefix: "/organization" }); await server.register(registerProjectRoleRouter, { prefix: "/workspace" }); + await server.register(registerSecretApprovalPolicyRouter, { prefix: "/secret-approvals" }); }; diff --git a/backend-pg/src/ee/routes/v1/secret-approval-policy-router.ts b/backend-pg/src/ee/routes/v1/secret-approval-policy-router.ts new file mode 100644 index 000000000..7c473a80a --- /dev/null +++ b/backend-pg/src/ee/routes/v1/secret-approval-policy-router.ts @@ -0,0 +1,154 @@ +import { z } from "zod"; +import { nanoid } from "nanoid"; +import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; +import { AuthMode } from "@app/services/auth/auth-type"; +import { sapPubSchema } from "@app/server/routes/sanitizedSchemas"; + +export const registerSecretApprovalPolicyRouter = async (server: FastifyZodProvider) => { + server.route({ + url: "/", + method: "POST", + schema: { + body: z + .object({ + workspaceId: z.string(), + name: z.string().optional(), + environment: z.string(), + secretPath: z.string().optional().nullable(), + approvers: z.string().array().min(1), + approvals: z.number().min(1).default(1) + }) + .refine((data) => data.approvals <= data.approvers.length, { + path: ["approvals"], + message: "The number of approvals should be lower than the number of approvers." + }), + response: { + 200: z.object({ + approval: sapPubSchema + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const approval = await server.services.secretApprovalPolicy.createSap({ + actor: req.permission.type, + actorId: req.permission.id, + projectId: req.body.workspaceId, + ...req.body, + name: req.body.name ?? `${req.body.environment}-${nanoid(3)}` + }); + return { approval }; + } + }); + + server.route({ + url: "/:sapId", + method: "PATCH", + schema: { + params: z.object({ + sapId: z.string() + }), + body: z + .object({ + name: z.string().optional(), + approvers: z.string().array().min(1), + approvals: z.number().min(1).default(1), + secretPath: z.string().optional().nullable() + }) + .refine((data) => data.approvals <= data.approvers.length, { + path: ["approvals"], + message: "The number of approvals should be lower than the number of approvers." + }), + response: { + 200: z.object({ + approval: sapPubSchema + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const approval = await server.services.secretApprovalPolicy.updateSap({ + actor: req.permission.type, + actorId: req.permission.id, + ...req.body, + secretPolicyId: req.params.sapId + }); + return { approval }; + } + }); + + server.route({ + url: "/:sapId", + method: "DELETE", + schema: { + params: z.object({ + sapId: z.string() + }), + response: { + 200: z.object({ + approval: sapPubSchema + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const approval = await server.services.secretApprovalPolicy.deleteSap({ + actor: req.permission.type, + actorId: req.permission.id, + secretPolicyId: req.params.sapId + }); + return { approval }; + } + }); + + server.route({ + url: "/", + method: "GET", + schema: { + querystring: z.object({ + workspaceId: z.string().trim() + }), + response: { + 200: z.object({ + approvals: sapPubSchema.merge(z.object({approvers:z.string().array()})).array() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const approvals = await server.services.secretApprovalPolicy.getSapByProjectId({ + actor: req.permission.type, + actorId: req.permission.id, + projectId: req.query.workspaceId + }); + return { approvals }; + } + }); + + server.route({ + url: "/board", + method: "GET", + schema: { + querystring: z.object({ + workspaceId: z.string().trim(), + environment: z.string().trim(), + secretPath: z.string().trim() + }), + response: { + 200: z.object({ + policy: sapPubSchema.merge(z.object({approvers:z.string().array()})).optional() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const policy = await server.services.secretApprovalPolicy.getSapOfFolder({ + actor: req.permission.type, + actorId: req.permission.id, + projectId: req.query.workspaceId, + ...req.query + }); + return { policy }; + } + }); +}; diff --git a/backend-pg/src/ee/services/secret-approval-policy/sap-approver-dal.ts b/backend-pg/src/ee/services/secret-approval-policy/sap-approver-dal.ts new file mode 100644 index 000000000..7996321d1 --- /dev/null +++ b/backend-pg/src/ee/services/secret-approval-policy/sap-approver-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 TSapApproverDalFactory = ReturnType; + +export const sapApproverDalFactory = (db: TDbClient) => { + const sapApproverOrm = ormify(db, TableName.SapApprover); + return sapApproverOrm; +}; diff --git a/backend-pg/src/ee/services/secret-approval-policy/secret-approval-policy-dal.ts b/backend-pg/src/ee/services/secret-approval-policy/secret-approval-policy-dal.ts new file mode 100644 index 000000000..22183ceb6 --- /dev/null +++ b/backend-pg/src/ee/services/secret-approval-policy/secret-approval-policy-dal.ts @@ -0,0 +1,85 @@ +import { TDbClient } from "@app/db"; +import { TSecretApprovalPolicies, TableName } from "@app/db/schemas"; +import { DatabaseError } from "@app/lib/errors"; +import { + TFindFilter, + buildFindFilter, + mergeOneToManyRelation, + ormify, + selectAllTableCols +} from "@app/lib/knex"; +import { Knex } from "knex"; + +export type TSecretApprovalPolicyDalFactory = ReturnType; + +export const secretApprovalPolicyDalFactory = (db: TDbClient) => { + const secretApprovalPolicyOrm = ormify(db, TableName.SecretApprovalPolicy); + + const sapFindQuery = (tx: Knex, filter: TFindFilter) => + tx(TableName.SecretApprovalPolicy) + .where(buildFindFilter(filter)) + .join( + TableName.Environment, + `${TableName.SecretApprovalPolicy}.envId`, + `${TableName.Environment}.id` + ) + .join( + TableName.SapApprover, + `${TableName.SecretApprovalPolicy}.id`, + `${TableName.SapApprover}.policyId` + ) + .select(tx.ref("approverId").withSchema(TableName.SapApprover)) + .select(tx.ref("name").withSchema(TableName.Environment).as("envName")) + .select(tx.ref("slug").withSchema(TableName.Environment).as("envSlug")) + .select(tx.ref("id").withSchema(TableName.Environment).as("envId")) + .select(tx.ref("projectId").withSchema(TableName.Environment)) + .select(selectAllTableCols(TableName.SecretApprovalPolicy)) + .orderBy("createdAt", "asc"); + + const findById = async (id: string, tx?: Knex) => { + try { + const doc = await sapFindQuery(tx || db, { + [`${TableName.SecretApprovalPolicy}.id` as "id"]: id + }); + const formatedDoc = mergeOneToManyRelation( + doc, + "id", + ({ approverId, envId, envName: name, envSlug: slug, ...el }) => ({ + ...el, + envId, + environment: { id: envId, name, slug } + }), + ({ approverId }) => approverId, + "approvers" + ); + return formatedDoc?.[0]; + } catch (error) { + throw new DatabaseError({ error, name: "FindById" }); + } + }; + + const find = async ( + filter: TFindFilter, + tx?: Knex + ) => { + try { + const docs = await sapFindQuery(tx || db, filter); + const formatedDoc = mergeOneToManyRelation( + docs, + "id", + ({ approverId, envId, envName: name, envSlug: slug, ...el }) => ({ + ...el, + envId, + environment: { id: envId, name, slug } + }), + ({ approverId }) => approverId, + "approvers" + ); + return formatedDoc; + } catch (error) { + throw new DatabaseError({ error, name: "Find" }); + } + }; + + return { ...secretApprovalPolicyOrm, findById, find }; +}; diff --git a/backend-pg/src/ee/services/secret-approval-policy/secret-approval-policy-service.ts b/backend-pg/src/ee/services/secret-approval-policy/secret-approval-policy-service.ts new file mode 100644 index 000000000..b96afc996 --- /dev/null +++ b/backend-pg/src/ee/services/secret-approval-policy/secret-approval-policy-service.ts @@ -0,0 +1,231 @@ +import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; +import { TSecretApprovalPolicyDalFactory } from "./secret-approval-policy-dal"; +import { TSapApproverDalFactory } from "./sap-approver-dal"; +import { + TCreateSapDTO, + TDeleteSapDTO, + TGetBoardSapDTO, + TListSapDTO, + TUpdateSapDTO +} from "./secret-approval-policy-types"; +import { ForbiddenError } from "@casl/ability"; +import { + ProjectPermissionActions, + ProjectPermissionSub +} from "@app/ee/services/permission/project-permission"; +import { BadRequestError } from "@app/lib/errors"; +import picomatch from "picomatch"; +import { containsGlobPatterns } from "@app/lib/picomatch"; +import { TProjectMembershipDalFactory } from "@app/services/project-membership/project-membership-dal"; +import { TProjectEnvDalFactory } from "@app/services/project-env/project-env-dal"; + +const getPolicyScore = (policy: { secretPath?: string | null }) => + // if glob pattern score is 1, if not exist score is 0 and if its not both then its exact path meaning score 2 + // eslint-disable-next-line + policy.secretPath ? (containsGlobPatterns(policy.secretPath) ? 1 : 2) : 0; + +type TSecretApprovalPolicyServiceFactoryDep = { + permissionService: Pick; + secretApprovalPolicyDal: TSecretApprovalPolicyDalFactory; + projectEnvDal: Pick; + sapApproverDal: TSapApproverDalFactory; + projectMembershipDal: Pick; +}; + +export type TSecretApprovalPolicyServiceFactory = ReturnType< + typeof secretApprovalPolicyServiceFactory +>; + +export const secretApprovalPolicyServiceFactory = ({ + secretApprovalPolicyDal, + permissionService, + sapApproverDal, + projectEnvDal, + projectMembershipDal +}: TSecretApprovalPolicyServiceFactoryDep) => { + const createSap = async ({ + name, + actor, + actorId, + approvals, + approvers, + projectId, + secretPath, + environment + }: TCreateSapDTO) => { + if (approvals > approvers.length) + throw new BadRequestError({ message: "Approvals cannot be greater than approvers" }); + + const { permission } = await permissionService.getProjectPermission(actor, actorId, projectId); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Create, + ProjectPermissionSub.SecretApproval + ); + const env = await projectEnvDal.findOne({ slug: environment, projectId }); + if (!env) throw new BadRequestError({ message: "Environment not found" }); + + const secretApprovers = await projectMembershipDal.find({ + projectId, + $in: { id: approvers } + }); + if (secretApprovers.length !== approvers.length) + throw new BadRequestError({ message: "Approver not found in project" }); + + const secretApproval = await secretApprovalPolicyDal.transaction(async (tx) => { + const doc = await secretApprovalPolicyDal.create( + { + envId: env.id, + approvals, + secretPath, + name + }, + tx + ); + await sapApproverDal.insertMany( + secretApprovers.map(({ id }) => ({ + approverId: id, + policyId: doc.id + })), + tx + ); + return doc; + }); + return { ...secretApproval, environment: env, projectId }; + }; + + const updateSap = async ({ + approvers, + secretPath, + name, + actorId, + actor, + approvals, + secretPolicyId + }: TUpdateSapDTO) => { + const secretApprovalPolicy = await secretApprovalPolicyDal.findById(secretPolicyId); + if (!secretApprovalPolicy) + throw new BadRequestError({ message: "Secret approval policy not found" }); + + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + secretApprovalPolicy.projectId + ); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Edit, + ProjectPermissionSub.SecretApproval + ); + + const updatedSap = await secretApprovalPolicyDal.transaction(async (tx) => { + const doc = await secretApprovalPolicyDal.updateById( + secretApprovalPolicy.id, + { + approvals, + secretPath, + name + }, + tx + ); + if (approvers) { + const secretApprovers = await projectMembershipDal.find( + { + projectId: secretApprovalPolicy.projectId, + $in: { id: approvers } + }, + tx + ); + if (secretApprovers.length !== approvers.length) + throw new BadRequestError({ message: "Approver not found in project" }); + if (doc.approvals > secretApprovers.length) + throw new BadRequestError({ message: "Approvals cannot be greater than approvers" }); + await sapApproverDal.delete({ policyId: doc.id }, tx); + await sapApproverDal.insertMany( + secretApprovers.map(({ id }) => ({ + approverId: id, + policyId: doc.id + })), + tx + ); + } + return doc; + }); + return { + ...updatedSap, + environment: secretApprovalPolicy.environment, + projectId: secretApprovalPolicy.projectId + }; + }; + + const deleteSap = async ({ secretPolicyId, actor, actorId }: TDeleteSapDTO) => { + const sapPolicy = await secretApprovalPolicyDal.findById(secretPolicyId); + if (!sapPolicy) throw new BadRequestError({ message: "Secret approval policy not found" }); + + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + sapPolicy.projectId + ); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Delete, + ProjectPermissionSub.SecretApproval + ); + + await secretApprovalPolicyDal.deleteById(secretPolicyId); + return sapPolicy; + }; + + const getSapByProjectId = async ({ actorId, actor, projectId }: TListSapDTO) => { + const { permission } = await permissionService.getProjectPermission(actor, actorId, projectId); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Read, + ProjectPermissionSub.SecretApproval + ); + + const sapPolicies = await secretApprovalPolicyDal.find({ projectId }); + return sapPolicies; + }; + + const getSapPolicy = async (projectId: string, environment: string, secretPath: string) => { + const env = await projectEnvDal.findOne({ slug: environment, projectId }); + if (!env) throw new BadRequestError({ message: "Environment not found" }); + + const policies = await secretApprovalPolicyDal.find({ envId: env.id }); + if (!policies.length) return; + // this will filter policies either without scoped to secret path or the one that matches with secret path + const policiesFilteredByPath = policies.filter( + ({ secretPath: policyPath }) => + !policyPath || picomatch.isMatch(secretPath, policyPath, { strictSlashes: false }) + ); + // now sort by priority. exact secret path gets first match followed by glob followed by just env scoped + // if that is tie get by first createdAt + const policiesByPriority = policiesFilteredByPath.sort( + (a, b) => getPolicyScore(b) - getPolicyScore(a) + ); + const finalPolicy = policiesByPriority.shift(); + return finalPolicy; + }; + + const getSapOfFolder = async ({ + projectId, + actor, + actorId, + environment, + secretPath + }: TGetBoardSapDTO) => { + const { permission } = await permissionService.getProjectPermission(actor, actorId, projectId); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Read, + ProjectPermissionSub.SecretApproval + ); + return getSapPolicy(projectId, environment, secretPath); + }; + + return { + createSap, + updateSap, + deleteSap, + getSapPolicy, + getSapByProjectId, + getSapOfFolder + }; +}; diff --git a/backend-pg/src/ee/services/secret-approval-policy/secret-approval-policy-types.ts b/backend-pg/src/ee/services/secret-approval-policy/secret-approval-policy-types.ts new file mode 100644 index 000000000..2ddd9b51b --- /dev/null +++ b/backend-pg/src/ee/services/secret-approval-policy/secret-approval-policy-types.ts @@ -0,0 +1,30 @@ +import { TProjectPermission } from "@app/lib/types"; + +export type TCreateSapDTO = { + approvals: number; + secretPath?: string | null; + environment: string; + approvers: string[]; + projectId: string; + name: string; +} & Omit; + +export type TUpdateSapDTO = { + secretPolicyId: string; + approvals?: number; + secretPath?: string | null; + approvers: string[]; + name?: string; +} & Omit; + +export type TDeleteSapDTO = { + secretPolicyId: string; +} & Omit; + +export type TListSapDTO = TProjectPermission; + +export type TGetBoardSapDTO = { + projectId: string; + environment: string; + secretPath: string; +} & Omit; diff --git a/backend-pg/src/ee/services/secret-approval-request/sar-reviewer-dal.ts b/backend-pg/src/ee/services/secret-approval-request/sar-reviewer-dal.ts new file mode 100644 index 000000000..3d967e52a --- /dev/null +++ b/backend-pg/src/ee/services/secret-approval-request/sar-reviewer-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 TSarReviewerDalFactory = ReturnType; + +export const sarReviewerDalFactory = (db: TDbClient) => { + const sarReviewerOrm = ormify(db, TableName.SarReviewer); + return sarReviewerOrm; +}; diff --git a/backend-pg/src/ee/services/secret-approval-request/sar-secret-dal.ts b/backend-pg/src/ee/services/secret-approval-request/sar-secret-dal.ts new file mode 100644 index 000000000..de86a9902 --- /dev/null +++ b/backend-pg/src/ee/services/secret-approval-request/sar-secret-dal.ts @@ -0,0 +1,28 @@ +import { TDbClient } from "@app/db"; +import { TableName } from "@app/db/schemas"; +import { DatabaseError } from "@app/lib/errors"; +import { ormify, selectAllTableCols } from "@app/lib/knex"; +import { Knex } from "knex"; + +export type TSarSecretDalFactory = ReturnType; + +export const sarSecretDalFactory = (db: TDbClient) => { + const sarSecretOrm = ormify(db, TableName.SarSecret); + + const findByRequestId = (requestId: string, tx?: Knex) => { + try { + const doc = (tx || db)(TableName.SarSecret) + .where({ requestId }) + .leftJoin(TableName.Secret, `${TableName.SarSecret}.secretId`, `${TableName.Secret}.id`) + .select(selectAllTableCols(TableName.SarSecret)) + .select( + db.ref("secretBlindIndex").withSchema(TableName.Secret).as("latestSecretBlindIndex"), + db.ref("version").withSchema(TableName.Secret).as("latestSecretVersion") + ); + return doc; + } catch (error) { + throw new DatabaseError({ error, name: "FindByRequestId" }); + } + }; + return { ...sarSecretOrm, findByRequestId }; +}; diff --git a/backend-pg/src/ee/services/secret-approval-request/secret-approval-request-dal.ts b/backend-pg/src/ee/services/secret-approval-request/secret-approval-request-dal.ts new file mode 100644 index 000000000..2fd193949 --- /dev/null +++ b/backend-pg/src/ee/services/secret-approval-request/secret-approval-request-dal.ts @@ -0,0 +1,242 @@ +import { TDbClient } from "@app/db"; +import { TSecretApprovalRequests, TableName } from "@app/db/schemas"; +import { DatabaseError } from "@app/lib/errors"; +import { TFindFilter, ormify, selectAllTableCols, sqlNestRelationships } from "@app/lib/knex"; +import { Knex } from "knex"; +import { RequestState } from "./secret-approval-request-types"; + +export type TSecretApprovalRequestDalFactory = ReturnType; + +type TFindQueryFilter = { + projectId: string; + membershipId: string; + status?: RequestState; + environment?: string; + committer?: string; + limit?: number; + offset?: number; +}; + +export const secretApprovalRequestDalFactory = (db: TDbClient) => { + const secretApprovalRequestOrm = ormify(db, TableName.SecretApprovalRequest); + + const findQuery = (filter: TFindFilter, tx: Knex) => + tx(TableName.SecretApprovalRequest) + .where(filter) + .join( + TableName.SecretApprovalPolicy, + `${TableName.SecretApprovalRequest}.policyId`, + `${TableName.SecretApprovalPolicy}.id` + ) + .join( + TableName.SarReviewer, + `${TableName.SecretApprovalRequest}.id`, + `${TableName.SarReviewer}.requestId` + ) + .join( + TableName.SapApprover, + `${TableName.SecretApprovalPolicy}.id`, + `${TableName.SapApprover}.policyId` + ) + .select(selectAllTableCols(TableName.SecretApprovalRequest)) + .select(tx.ref("id").withSchema(TableName.SarReviewer).as("reviewerMemberId")) + .select(tx.ref("statue").withSchema(TableName.SarReviewer).as("reviewerStatus")) + .select(tx.ref("id").withSchema(TableName.SecretApprovalPolicy).as("policyId")) + .select(tx.ref("name").withSchema(TableName.SecretApprovalPolicy).as("policyName")) + .select(tx.ref("projectId").withSchema(TableName.SecretApprovalPolicy)) + .select( + tx.ref("secretPath").withSchema(TableName.SecretApprovalPolicy).as("policySecretPath") + ) + .select(tx.ref("approvals").withSchema(TableName.SecretApprovalPolicy).as("policyApprovals")) + .select(tx.ref("approverId").withSchema(TableName.SapApprover)); + + const findById = async (id: string, tx?: Knex) => { + try { + const docs = await findQuery({ id }, tx || db); + const formatedDoc = sqlNestRelationships({ + data: docs, + key: "id", + parentMapper: ({ + id: pk, + projectId, + hasMerged, + status, + conflicts, + slug, + folderId, + statusChangeBy, + committerId, + createdAt, + updatedAt, + policyId, + policyName, + policyApprovals, + policySecretPath + }) => ({ + id: pk, + hasMerged, + projectId, + status, + conflicts, + slug, + folderId, + statusChangeBy, + committerId, + createdAt, + updatedAt, + policyId, + policy: { + id: policyId, + name: policyName, + approvals: policyApprovals, + secretPath: policySecretPath + } + }), + childrenMapper: [ + { + key: "reviewerMemberId", + label: "reviewers", + mapper: ({ reviewerMemberId: member, reviewerStatus: status }) => ({ member, status }) + }, + { key: "approverId", label: "approvers", mapper: ({ approverId }) => approverId } + ] as const + }); + if (!formatedDoc?.[0]) return; + return { + ...formatedDoc[0], + policy: { ...formatedDoc[0].policy, approvers: formatedDoc[0].approvers } + }; + } catch (error) { + throw new DatabaseError({ error, name: "FindByIdSAR" }); + } + }; + + const findProjectRequestCount = async (projectId: string, membershipId: string, tx?: Knex) => { + try { + const doc = await (tx || db)(TableName.SecretApprovalRequest) + .join( + TableName.SecretFolder, + `${TableName.SecretApprovalRequest}.folderId`, + `${TableName.SecretFolder}.id` + ) + .join( + TableName.Environment, + `${TableName.SecretFolder}.envId`, + `${TableName.Environment}.id` + ) + .where({ projectId }) + .join( + TableName.SapApprover, + `${TableName.SecretApprovalRequest}.policyId`, + `${TableName.SapApprover}.policyId` + ) + .where(`${TableName.SapApprover}.approverId`, membershipId) + .orWhere(`${TableName.SecretApprovalRequest}.committerId`, membershipId) + .groupBy("status") + .count("status"); + console.log(JSON.stringify(doc, null, 4)); + return { open: 0, closed: 0 }; + } catch (error) { + throw new DatabaseError({ error, name: "FindRequestCount" }); + } + }; + + const findByProjectId = async ( + { status, limit, offset, projectId, committer, environment, membershipId }: TFindQueryFilter, + tx?: Knex + ) => { + try { + const docs = await (tx || db)(TableName.SecretApprovalRequest) + .join( + TableName.SecretFolder, + `${TableName.SecretApprovalRequest}.folderId`, + `${TableName.SecretFolder}.id` + ) + .join( + TableName.Environment, + `${TableName.SecretFolder}.envId`, + `${TableName.Environment}.id` + ) + .where({ projectId, slug: environment, status, committerId: committer }) + .join( + TableName.SecretApprovalPolicy, + `${TableName.SecretApprovalRequest}.policyId`, + `${TableName.SecretApprovalPolicy}.id` + ) + .join( + TableName.SapApprover, + `${TableName.SecretApprovalPolicy}.id`, + `${TableName.SapApprover}.policyId` + ) + .where(`${TableName.SapApprover}.approverId`, membershipId) + .orWhere(`${TableName.SecretApprovalRequest}.committerId`, membershipId) + .select(selectAllTableCols(TableName.SecretApprovalRequest)) + .select(db.ref("id").withSchema(TableName.SarReviewer).as("reviewerMemberId")) + .select(db.ref("statue").withSchema(TableName.SarReviewer).as("reviewerStatus")) + .select(db.ref("id").withSchema(TableName.SecretApprovalPolicy).as("policyId")) + .select(db.ref("name").withSchema(TableName.SecretApprovalPolicy).as("policyName")) + .select( + db.ref("secretPath").withSchema(TableName.SecretApprovalPolicy).as("policySecretPath") + ) + .select( + db.ref("approvals").withSchema(TableName.SecretApprovalPolicy).as("policyApprovals") + ) + .select(db.ref("approverId").withSchema(TableName.SapApprover)); + const formatedDoc = sqlNestRelationships({ + data: docs, + key: "id", + parentMapper: ({ + id: pk, + hasMerged, + status: pStatus, + conflicts, + slug, + folderId, + statusChangeBy, + committerId, + createdAt, + updatedAt, + policyId, + policyName, + policyApprovals, + policySecretPath + }) => ({ + id: pk, + hasMerged, + projectId, + status: pStatus, + conflicts, + slug, + folderId, + statusChangeBy, + committerId, + createdAt, + updatedAt, + policyId, + policy: { + id: policyId, + name: policyName, + approvals: policyApprovals, + secretPath: policySecretPath + } + }), + childrenMapper: [ + { + key: "reviewerMemberId", + label: "reviewers", + mapper: ({ reviewerMemberId: member, reviewerStatus: s }) => ({ member, status: s }) + }, + { key: "approverId", label: "approvers", mapper: ({ approverId }) => approverId } + ] as const + }); + return formatedDoc.map((el) => ({ + ...el, + policy: { ...el.policy, approvers: el.approvers } + })); + } catch (error) { + throw new DatabaseError({ error, name: "FindSAR" }); + } + }; + + return { ...secretApprovalRequestOrm, findById, findProjectRequestCount, findByProjectId }; +}; diff --git a/backend-pg/src/ee/services/secret-approval-request/secret-approval-request-service.ts b/backend-pg/src/ee/services/secret-approval-request/secret-approval-request-service.ts new file mode 100644 index 000000000..a2c4caabe --- /dev/null +++ b/backend-pg/src/ee/services/secret-approval-request/secret-approval-request-service.ts @@ -0,0 +1,673 @@ +import { TSecretFolderDalFactory } from "@app/services/secret-folder/secret-folder-dal"; +import { TSecretApprovalRequestDalFactory } from "./secret-approval-request-dal"; +import { + ApprovalStatus, + CommitType, + TApprovalRequestCountDTO, + TGenerateSecretApprovalRequestDTO, + TListApprovalsDTO, + TMergeSecretApprovalRequestDTO, + TReviewRequestDTO, + TSecretApprovalDetailsDTO, + TStatusChangeDTO +} from "./secret-approval-request-types"; +import { BadRequestError, UnauthorizedError } from "@app/lib/errors"; +import { TSecretBlindIndexDalFactory } from "@app/services/secret/secret-blind-index-dal"; +import { generateSecretBlindIndexBySalt } from "@app/services/secret/secret-service"; +import { + ProjectMembershipRole, + SecretEncryptionAlgo, + SecretKeyEncoding, + SecretType, + TSaRequestSecretsInsert, + TSecrets +} from "@app/db/schemas"; +import { TSecretDalFactory } from "@app/services/secret/secret-dal"; +import { TSecretVersionDalFactory } from "@app/services/secret/secret-version-dal"; +import { alphaNumericNanoId } from "@app/lib/nanoid"; +import { TSarSecretDalFactory } from "./sar-secret-dal"; +import { ActorType } from "@app/services/auth/auth-type"; +import { TPermissionServiceFactory } from "../permission/permission-service"; +import { TSarReviewerDalFactory } from "./sar-reviewer-dal"; + +type TSecretApprovalRequestServiceFactoryDep = { + permissionService: Pick; + secretApprovalRequestDal: TSecretApprovalRequestDalFactory; + secretDal: TSecretDalFactory; + sarSecretDal: TSarSecretDalFactory; + sarReviewerDal: TSarReviewerDalFactory; + secretVersionDal: Pick; + folderDal: Pick; + secretBlindIndexDal: Pick; +}; + +export type TSecretApprovalRequestServiceFactory = ReturnType< + typeof secretApprovalRequestServiceFactory +>; + +export const secretApprovalRequestServiceFactory = ({ + secretApprovalRequestDal, + folderDal, + secretDal, + sarReviewerDal, + sarSecretDal, + secretVersionDal, + secretBlindIndexDal, + permissionService +}: TSecretApprovalRequestServiceFactoryDep) => { + const requestCount = async ({ projectId, actor, actorId }: TApprovalRequestCountDTO) => { + const { membership } = await permissionService.getProjectPermission(actor, actorId, projectId); + const count = await secretApprovalRequestDal.findProjectRequestCount(projectId, membership.id); + return count; + }; + + const getSecretApprovals = async ({ + projectId, + actorId, + actor, + status, + environment, + committer + }: TListApprovalsDTO) => { + const { membership } = await permissionService.getProjectPermission(actor, actorId, projectId); + const approvals = await secretApprovalRequestDal.findByProjectId({ + projectId, + committer, + environment, + status, + membershipId: membership.id + }); + return approvals; + }; + + const getSecretApprovalDetails = async ({ actor, actorId, id }: TSecretApprovalDetailsDTO) => { + const secretApprovalRequest = await secretApprovalRequestDal.findById(id); + if (!secretApprovalRequest) + throw new BadRequestError({ message: "Secret approval request not found" }); + + const { policy } = secretApprovalRequest; + const { membership } = await permissionService.getProjectPermission( + actor, + actorId, + secretApprovalRequest.projectId + ); + if ( + membership.role !== ProjectMembershipRole.Admin && + secretApprovalRequest.committerId !== membership.id && + !policy.approvers.find((approverId) => approverId === membership.id) + ) { + throw new UnauthorizedError({ message: "User has no access" }); + } + + const secrets = await sarSecretDal.findByRequestId(secretApprovalRequest.id); + return { ...secretApprovalRequest, commits: secrets }; + }; + + const reviewApproval = async ({ approvalId, actor, status, actorId }: TReviewRequestDTO) => { + const secretApprovalRequest = await secretApprovalRequestDal.findById(approvalId); + if (!secretApprovalRequest) + throw new BadRequestError({ message: "Secret approval request not found" }); + if (actor !== ActorType.USER) throw new BadRequestError({ message: "Must be a user" }); + + const { policy } = secretApprovalRequest; + const { membership } = await permissionService.getProjectPermission( + ActorType.USER, + actorId, + secretApprovalRequest.projectId + ); + if ( + membership.role !== ProjectMembershipRole.Admin && + secretApprovalRequest.committerId !== membership.id && + !policy.approvers.find((approverId) => approverId === membership.id) + ) { + throw new UnauthorizedError({ message: "User has no access" }); + } + const reviewStatus = await sarReviewerDal.transaction(async (tx) => { + const review = await sarReviewerDal.findOne( + { + requestId: secretApprovalRequest.id, + member: membership.id + }, + tx + ); + if (!review) { + return sarReviewerDal.create( + { + status, + requestId: secretApprovalRequest.id, + member: membership.id + }, + tx + ); + } + return sarReviewerDal.updateById(review.id, { status }, tx); + }); + return reviewStatus; + }; + + const updateApprovalStatus = async ({ actorId, status, approvalId, actor }: TStatusChangeDTO) => { + const secretApprovalRequest = await secretApprovalRequestDal.findById(approvalId); + if (!secretApprovalRequest) + throw new BadRequestError({ message: "Secret approval request not found" }); + if (actor !== ActorType.USER) throw new BadRequestError({ message: "Must be a user" }); + + const { policy } = secretApprovalRequest; + const { membership } = await permissionService.getProjectPermission( + ActorType.USER, + actorId, + secretApprovalRequest.projectId + ); + if ( + membership.role !== ProjectMembershipRole.Admin && + secretApprovalRequest.committerId !== membership.id && + !policy.approvers.find((approverId) => approverId === membership.id) + ) { + throw new UnauthorizedError({ message: "User has no access" }); + } + + if (secretApprovalRequest.hasMerged) + throw new BadRequestError({ message: "Approval request has been merged" }); + if (secretApprovalRequest.status === "close" && status === "close") + throw new BadRequestError({ message: "Approval request is already closed" }); + if (secretApprovalRequest.status === "open" && status === "open") + throw new BadRequestError({ message: "Approval request is already open" }); + + const updatedRequest = await secretApprovalRequestDal.updateById(secretApprovalRequest.id, { + status, + statusChangeBy: membership.id + }); + return updatedRequest; + }; + + const mergeSecretApprovalRequest = async ({ + approvalId, + actor, + actorId + }: TMergeSecretApprovalRequestDTO) => { + const secretApprovalRequest = await secretApprovalRequestDal.findById(approvalId); + if (!secretApprovalRequest) + throw new BadRequestError({ message: "Secret approval request not found" }); + if (actor !== ActorType.USER) throw new BadRequestError({ message: "Must be a user" }); + + const { policy, folderId } = secretApprovalRequest; + const { membership } = await permissionService.getProjectPermission( + ActorType.USER, + actorId, + secretApprovalRequest.projectId + ); + if ( + membership.role !== ProjectMembershipRole.Admin && + secretApprovalRequest.committerId !== membership.id && + !policy.approvers.find((approverId) => approverId === membership.id) + ) { + throw new UnauthorizedError({ message: "User has no access" }); + } + const reviewers = secretApprovalRequest.reviewers.reduce>( + (prev, curr) => ({ ...prev, [curr.member.toString()]: curr.status }), + {} + ); + const hasMinApproval = + secretApprovalRequest.policy.approvals <= + secretApprovalRequest.policy.approvers.filter( + (approverId) => reviewers[approverId.toString()] === ApprovalStatus.APPROVED + ).length; + + if (!hasMinApproval) + throw new BadRequestError({ message: "Doesn't have minimum approvals needed" }); + const secretApprovalSecrets = await sarSecretDal.findByRequestId(secretApprovalRequest.id); + if (!secretApprovalSecrets) throw new BadRequestError({ message: "No secrets found" }); + + const conflicts: Array<{ secretId: string; op: CommitType }> = []; + let secretCreationCommits = secretApprovalSecrets.filter(({ op }) => op === CommitType.Create); + if (secretCreationCommits.length) { + const conflictedSecrets = await secretDal.findByBlindIndexes( + folderId, + secretCreationCommits.map(({ secretBlindIndex }) => ({ + type: SecretType.Shared, + blindIndex: secretBlindIndex + })) + ); + const conflictGroupByBlindIndex = conflictedSecrets.reduce>( + (prev, curr) => ({ ...prev, [curr.secretBlindIndex || ""]: true }), + {} + ); + secretCreationCommits + .filter(({ secretBlindIndex }) => conflictGroupByBlindIndex[secretBlindIndex || ""]) + .forEach((el) => { + conflicts.push({ op: CommitType.Create, secretId: el.id }); + }); + secretCreationCommits = secretCreationCommits.filter( + ({ secretBlindIndex }) => !conflictGroupByBlindIndex[secretBlindIndex || ""] + ); + } + + let secretUpdationCommits = secretApprovalSecrets.filter(({ op }) => op === CommitType.Update); + if (secretUpdationCommits.length) { + const conflictedByNewBlindIndex = await secretDal.findByBlindIndexes( + folderId, + secretUpdationCommits.map(({ secretBlindIndex }) => ({ + type: SecretType.Shared, + blindIndex: secretBlindIndex + })) + ); + const conflictGroupByBlindIndex = conflictedByNewBlindIndex.reduce>( + (prev, curr) => + curr?.secretBlindIndex ? { ...prev, [curr.secretBlindIndex]: true } : prev, + {} + ); + secretUpdationCommits + .filter( + ({ secretBlindIndex, secretId }) => + (secretBlindIndex && conflictGroupByBlindIndex[secretBlindIndex]) || !secretId + ) + .forEach((el) => { + conflicts.push({ op: CommitType.Update, secretId: el.id }); + }); + + secretUpdationCommits = secretUpdationCommits.filter( + ({ secretBlindIndex, secretId }) => + Boolean(secretId) && + (secretBlindIndex ? !conflictGroupByBlindIndex[secretBlindIndex] : true) + ); + } + + const secretDeletionCommits = secretApprovalSecrets.filter( + ({ op }) => op === CommitType.Delete + ); + + const mergeStatus = await secretDal.transaction(async (tx) => { + const newSecrets = await secretDal.insertMany( + secretCreationCommits.map( + ({ + secretBlindIndex, + metadata, + secretKeyIV, + secretKeyTag, + secretKeyCiphertext, + secretValueIV, + secretValueTag, + secretValueCiphertext, + secretCommentIV, + secretCommentTag, + secretCommentCiphertext, + skipMultilineEncoding, + secretReminderNotice, + secretReminderRepeatDays + }) => ({ + secretBlindIndex, + metadata, + secretKeyIV, + secretKeyTag, + secretKeyCiphertext, + secretValueIV, + secretValueTag, + secretValueCiphertext, + secretCommentIV, + secretCommentTag, + secretCommentCiphertext, + skipMultilineEncoding, + secretReminderNotice, + secretReminderRepeatDays, + version: 1, + folderId, + type: SecretType.Shared, + algorithm: SecretEncryptionAlgo.AES_256_GCM, + keyEncoding: SecretKeyEncoding.UTF8 + }) + ), + tx + ); + const updatedSecrets = await secretDal.bulkUpdate( + secretUpdationCommits.map( + ({ + secretId, + secretBlindIndex, + metadata, + secretKeyIV, + secretKeyTag, + secretKeyCiphertext, + secretValueIV, + secretValueTag, + secretValueCiphertext, + secretCommentIV, + secretCommentTag, + secretCommentCiphertext, + skipMultilineEncoding, + secretReminderNotice, + secretReminderRepeatDays + }) => ({ + folderId, + id: secretId as string, + type: SecretType.Shared, + secretBlindIndex, + metadata, + secretKeyIV, + secretKeyTag, + secretKeyCiphertext, + secretValueIV, + secretValueTag, + secretValueCiphertext, + secretCommentIV, + secretCommentTag, + secretCommentCiphertext, + skipMultilineEncoding, + secretReminderNotice, + secretReminderRepeatDays + }) + ), + tx + ); + const deletedSecret = await secretDal.deleteMany( + secretDeletionCommits.map(({ secretBlindIndex }) => ({ + blindIndex: secretBlindIndex, + type: SecretType.Shared + })), + folderId, + actorId, + tx + ); + await secretVersionDal.insertMany( + newSecrets + .map(({ id, updatedAt, createdAt, ...el }) => ({ + ...el, + secretId: id + })) + .concat( + updatedSecrets.map(({ id, updatedAt, createdAt, ...el }) => ({ + ...el, + secretId: id + })) + ), + tx + ); + + const updatedSecretApproval = await secretApprovalRequestDal.updateById( + secretApprovalRequest.id, + { + conflicts, + hasMerged: true, + status: "close", + statusChangeBy: actorId + }, + tx + ); + return { + secrets: { created: newSecrets, updated: updatedSecrets, deleted: deletedSecret }, + approval: updatedSecretApproval + }; + }); + return mergeStatus; + }; + + // function to save secret change to secret approval + // this will keep a copy to do merge later when accepting + const generateSecretApprovalRequest = async ({ + data, + policy, + projectId, + secretPath, + environment, + commiterMembershipId + }: TGenerateSecretApprovalRequestDTO) => { + const folder = await folderDal.findBySecretPath(projectId, environment, secretPath); + if (!folder) + throw new BadRequestError({ message: "Folder not found", name: "GenSecretApproval" }); + const folderId = folder.id; + + const blindIndexDoc = await secretBlindIndexDal.findOne({ projectId }); + if (!blindIndexDoc) + throw new BadRequestError({ message: "Blind index not found", name: "Update secret" }); + + const commits: Omit[] = []; + // for created secret approval change + const createdSecrets = data[CommitType.Create]; + if (createdSecrets && createdSecrets?.length) { + const secretBlindIndexToKey: Record = {}; // used at audit log point + const secretBlindIndexes = await Promise.all( + createdSecrets.map(({ secretName }) => + generateSecretBlindIndexBySalt(secretName, blindIndexDoc) + ) + ).then((blindIndexes) => + blindIndexes.reduce>((prev, curr, i) => { + // eslint-disable-next-line + prev[createdSecrets[i].secretName] = curr; + secretBlindIndexToKey[curr] = createdSecrets[i].secretName; + return prev; + }, {}) + ); + + const exists = await secretDal.findByBlindIndexes( + folderId, + createdSecrets.map(({ secretName }) => ({ + blindIndex: secretBlindIndexes[secretName], + type: SecretType.Shared + })) + ); + if (exists.length) throw new BadRequestError({ message: "Secret already exist" }); + commits.push( + ...createdSecrets.map((el) => ({ + ...el, + op: CommitType.Create as const, + version: 0, + secretBlindIndex: secretBlindIndexes[el.secretName] + })) + ); + } + // not secret approval for update operations + const updatedSecrets = data[CommitType.Update]; + if (updatedSecrets && updatedSecrets?.length) { + // 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( + updatedSecrets.map(({ secretName }) => + generateSecretBlindIndexBySalt(secretName, blindIndexDoc) + ) + ).then((blindIndexes) => + blindIndexes.reduce>((prev, curr, i) => { + // eslint-disable-next-line + prev[updatedSecrets[i].secretName] = curr; + secretBlindIndexToKey[curr] = updatedSecrets[i].secretName; + return prev; + }, {}) + ); + + const secretsToBeUpdated = await secretDal.findByBlindIndexes( + folderId, + updatedSecrets.map(({ secretName }) => ({ + blindIndex: secretBlindIndexes[secretName], + type: SecretType.Shared + })) + ); + if (secretsToBeUpdated.length !== updatedSecrets.length) + throw new BadRequestError({ message: "Secret not found" }); + + // now find any secret that needs to update its name + // same process as above + const nameUpdatedSecrets = updatedSecrets.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(({ newSecretName }) => ({ + blindIndex: newSecretBlindIndexes[newSecretName as string], + type: SecretType.Shared + })) + ); + if (secretsWithNewName.length) + throw new BadRequestError({ message: "Secret with new name already exist" }); + + const secretsGroupedByBlindIndex = secretsToBeUpdated.reduce>( + (prev, curr) => { + // eslint-disable-next-line + if (curr.secretBlindIndex) prev[curr.secretBlindIndex] = curr; + return prev; + }, + {} + ); + const updatedSecretIds = updatedSecrets.map( + (el) => secretsGroupedByBlindIndex[secretBlindIndexes[el.secretName]].id + ); + const latestSecretVersions = await secretVersionDal.findLatestVersionMany( + folderId, + updatedSecretIds + ); + commits.push( + ...updatedSecrets.map((el) => { + const secretId = secretsGroupedByBlindIndex[secretBlindIndexes[el.secretName]].id; + return { + ...latestSecretVersions[secretId], + op: CommitType.Update as const, + secret: secretId, + secretVersion: latestSecretVersions[secretId].id, + ...el, + secretBlindIndex: newSecretBlindIndexes?.[el.secretName], + version: secretsGroupedByBlindIndex[secretBlindIndexes[el.secretName]].version || 1 + }; + }) + ); + } + // deleted secrets + const deletedSecrets = data[CommitType.Delete]; + if (deletedSecrets && deletedSecrets.length) { + // 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( + deletedSecrets.map(({ secretName }) => + generateSecretBlindIndexBySalt(secretName, blindIndexDoc) + ) + ).then((blindIndexes) => + blindIndexes.reduce>((prev, curr, i) => { + // eslint-disable-next-line + prev[deletedSecrets[i].secretName] = curr; + secretBlindIndexToKey[curr] = deletedSecrets[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, + deletedSecrets.map(({ secretName }) => ({ + blindIndex: secretBlindIndexes[secretName], + type: SecretType.Shared + })) + ); + if (secretsToBeDeleted.length !== deletedSecrets.length) + throw new BadRequestError({ message: "Secret not found" }); + const secretsGroupedByBlindIndex = secretsToBeDeleted.reduce>( + (prev, curr) => { + // eslint-disable-next-line + if (curr.secretBlindIndex) prev[curr.secretBlindIndex] = curr; + return prev; + }, + {} + ); + const deletedSecretIds = deletedSecrets.map( + (el) => secretsGroupedByBlindIndex[secretBlindIndexes[el.secretName]].id + ); + const latestSecretVersions = await secretVersionDal.findLatestVersionMany( + folderId, + deletedSecretIds + ); + commits.push( + ...deletedSecrets.map((el) => { + const secretId = secretsGroupedByBlindIndex[secretBlindIndexes[el.secretName]].id; + return { + op: CommitType.Delete as const, + ...latestSecretVersions[secretId], + secret: secretId, + secretVersion: latestSecretVersions[secretId].id + }; + }) + ); + } + + const secretApprovalRequest = await secretApprovalRequestDal.transaction(async (tx) => { + const doc = await secretApprovalRequestDal.create( + { + folderId, + slug: alphaNumericNanoId(), + policyId: policy.id, + status: "open", + hasMerged: false, + committerId: commiterMembershipId + }, + tx + ); + const approvalCommits = await sarSecretDal.insertMany( + commits.map( + ({ + version, + op, + secretKeyTag, + secretKeyIV, + keyEncoding, + secretId, + metadata, + algorithm, + secretBlindIndex, + secretValueIV, + secretValueTag, + secretVersion, + secretCommentIV, + secretCommentTag, + secretKeyCiphertext, + secretValueCiphertext, + secretReminderNotice, + skipMultilineEncoding, + secretCommentCiphertext, + secretReminderRepeatDays + }) => ({ + version, + requestId: doc.id, + op, + secretKeyTag, + secretKeyIV, + keyEncoding, + secretId, + metadata, + algorithm, + secretBlindIndex, + secretValueIV, + secretValueTag, + secretVersion, + secretCommentIV, + secretCommentTag, + secretKeyCiphertext, + secretValueCiphertext, + secretReminderNotice, + skipMultilineEncoding, + secretCommentCiphertext, + secretReminderRepeatDays + }) + ), + tx + ); + return { ...doc, commits: approvalCommits }; + }); + return secretApprovalRequest; + }; + return { + generateSecretApprovalRequest, + mergeSecretApprovalRequest, + reviewApproval, + updateApprovalStatus, + getSecretApprovals, + getSecretApprovalDetails, + requestCount + }; +}; diff --git a/backend-pg/src/ee/services/secret-approval-request/secret-approval-request-types.ts b/backend-pg/src/ee/services/secret-approval-request/secret-approval-request-types.ts new file mode 100644 index 000000000..43de12008 --- /dev/null +++ b/backend-pg/src/ee/services/secret-approval-request/secret-approval-request-types.ts @@ -0,0 +1,71 @@ +import { TImmutableDBKeys, TSaRequestSecrets, TSecretApprovalPolicies } from "@app/db/schemas"; +import { TProjectPermission } from "@app/lib/types"; + +export enum CommitType { + Create = "create", + Update = "update", + Delete = "delete" +} + +export enum RequestState { + Open = "open", + Closed = "closed" +} + +export enum ApprovalStatus { + PENDING = "pending", + APPROVED = "approved", + REJECTED = "rejected" +} + +type TApprovalCreateSecret = Omit & { + secretName: string; + tagIds?: string[]; +}; +type TApprovalUpdateSecret = Partial> & { + secretName: string; + newSecretName?: string; + tagIds?: string[]; +}; + +export type TGenerateSecretApprovalRequestDTO = { + projectId: string; + environment: string; + secretPath: string; + policy: TSecretApprovalPolicies; + commiterMembershipId: string; + data: { + [CommitType.Create]: TApprovalCreateSecret[]; + [CommitType.Update]: TApprovalUpdateSecret[]; + [CommitType.Delete]: { secretName: string }[]; + }; +}; + +export type TMergeSecretApprovalRequestDTO = { + approvalId: string; +} & Omit; + +export type TStatusChangeDTO = { + approvalId: string; + status: "open" | "close"; +} & Omit; + +export type TReviewRequestDTO = { + approvalId: string; + status: ApprovalStatus; +} & Omit; + +export type TApprovalRequestCountDTO = TProjectPermission; + +export type TListApprovalsDTO = { + projectId: string; + status?: RequestState; + environment?: string; + committer?: string; + limit?: number; + offset?: number; +} & TProjectPermission; + +export type TSecretApprovalDetailsDTO = { + id:string; +} & Omit diff --git a/backend-pg/src/lib/knex/index.ts b/backend-pg/src/lib/knex/index.ts index 68485709e..543da9255 100644 --- a/backend-pg/src/lib/knex/index.ts +++ b/backend-pg/src/lib/knex/index.ts @@ -15,6 +15,21 @@ export const withTransaction = (db: Knex, dal: K) => ({ ...dal }); +export type TFindFilter = Partial & { + $in?: Partial<{ [K in keyof R]: R[K][] }>; +}; +export const buildFindFilter = + ({ $in, ...filter }: TFindFilter) => + (bd: Knex.QueryBuilder) => { + bd.where(filter); + if ($in) { + Object.entries($in).forEach(([key, val]) => { + bd.whereIn(key as any, val as any); + }); + } + return bd; + }; + // What is ormify // It is to inject typical operations like find, findOne, update, delete, create // This will avoid writing most common ones each time @@ -45,9 +60,10 @@ export const ormify = ( throw new DatabaseError({ error, name: "Find one" }); } }, - find: (filter: Partial, tx?: Knex) => { + find: async (filter: TFindFilter, tx?: Knex) => { try { - return (tx || db)(tableName).where(filter); + const res = await (tx || db)(tableName).where(buildFindFilter(filter)); + return res; } catch (error) { throw new DatabaseError({ error, name: "Find one" }); } diff --git a/backend-pg/src/lib/knex/join.ts b/backend-pg/src/lib/knex/join.ts index cd2f1e3a2..73999c2ff 100644 --- a/backend-pg/src/lib/knex/join.ts +++ b/backend-pg/src/lib/knex/join.ts @@ -2,7 +2,7 @@ export const mergeOneToManyRelation = < T extends Record, Pk extends keyof T, P extends Record, - C extends Record, + C extends any, Ck extends string = "child" >( data: T[], @@ -29,3 +29,61 @@ export const mergeOneToManyRelation = < } return groupedRecord; }; + +export type TSqlPackRelationships< + T extends Record, + P extends Record, + C extends TChildMapper[] +> = { + data: T[]; + key: keyof T; + parentMapper: (arg: T) => P; + childrenMapper: C; +}; + +export type TChildMapper = { + key: keyof T; + label: U; + mapper: (arg: T) => R; +}; + +type MappedRecord> = { + [K in T["label"]]: ReturnType["mapper"]>[]; +}; + +export const sqlNestRelationships = < + T extends Record = {}, + P extends Record = {}, + C extends TChildMapper[] = TChildMapper[] +>({ + data, + key, + parentMapper, + childrenMapper +}: TSqlPackRelationships) => { + const parentLookup = new Set(); + const childLookUp = new Set(); + const recordsOrder: string[] = []; + + type Cm = MappedRecord<(typeof childrenMapper)[number]>; + + const recordsGroupedByPk: Record = {}; + data.forEach((el) => { + const pk = el[key]; + if (!parentLookup.has(pk)) { + recordsGroupedByPk[pk] = parentMapper(el) as P & Cm; + recordsOrder.push(pk); + parentLookup.add(pk); + } + childrenMapper.forEach(({ label, mapper, key: cKey }) => { + const ck = `${pk}-${label}-${el[cKey]}`; + if (!childLookUp.has(ck)) { + if (!recordsGroupedByPk[pk][label]) recordsGroupedByPk[pk][label as keyof Cm] = [] as any; + const val = mapper(el); + if (typeof val !== "undefined") recordsGroupedByPk[pk][label].push(val); + childLookUp.add(ck); + } + }); + }); + return recordsOrder.map((pkId) => recordsGroupedByPk[pkId]); +}; diff --git a/backend-pg/src/lib/nanoid/index.ts b/backend-pg/src/lib/nanoid/index.ts new file mode 100644 index 000000000..7cfd41324 --- /dev/null +++ b/backend-pg/src/lib/nanoid/index.ts @@ -0,0 +1,4 @@ +import { customAlphabet } from "nanoid"; + +const SLUG_ALPHABETS = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"; +export const alphaNumericNanoId = customAlphabet(SLUG_ALPHABETS, 10); diff --git a/backend-pg/src/lib/picomatch/index.ts b/backend-pg/src/lib/picomatch/index.ts new file mode 100644 index 000000000..1e222d72c --- /dev/null +++ b/backend-pg/src/lib/picomatch/index.ts @@ -0,0 +1,7 @@ +import path from "node:path"; + +export function containsGlobPatterns(secretPath: string) { + const globChars = ["*", "?", "[", "]", "{", "}", "**"]; + const normalizedPath = path.normalize(secretPath); + return globChars.some((char) => normalizedPath.includes(char)); +} diff --git a/backend-pg/src/server/routes/index.ts b/backend-pg/src/server/routes/index.ts index 54edd5580..d3c603e65 100644 --- a/backend-pg/src/server/routes/index.ts +++ b/backend-pg/src/server/routes/index.ts @@ -69,6 +69,9 @@ import { injectPermission } from "../plugins/auth/inject-permission"; import { registerV1Routes } from "./v1"; import { registerV2Routes } from "./v2"; import { registerV3Routes } from "./v3"; +import { secretApprovalPolicyDalFactory } from "@app/ee/services/secret-approval-policy/secret-approval-policy-dal"; +import { sapApproverDalFactory } from "@app/ee/services/secret-approval-policy/sap-approver-dal"; +import { secretApprovalPolicyServiceFactory } from "@app/ee/services/secret-approval-policy/secret-approval-policy-service"; export const registerRoutes = async ( server: FastifyZodProvider, @@ -113,9 +116,18 @@ export const registerRoutes = async ( // ee db layer ops const permissionDal = permissionDalFactory(db); + const sapApproverDal = sapApproverDalFactory(db); + const secretApprovalPolicyDal = secretApprovalPolicyDalFactory(db); // ee services const permissionService = permissionServiceFactory({ permissionDal, orgRoleDal, projectRoleDal }); + const sapService = secretApprovalPolicyServiceFactory({ + projectMembershipDal, + projectEnvDal, + sapApproverDal, + permissionService, + secretApprovalPolicyDal + }); // service layers const tokenService = tokenServiceFactory({ tokenDal: authTokenDal }); @@ -274,7 +286,8 @@ export const registerRoutes = async ( identity: identityService, identityAccessToken: identityAccessTokenService, identityProject: identityProjectService, - identityUa: identityUaService + identityUa: identityUaService, + secretApprovalPolicy: sapService }); server.decorate("store", { diff --git a/backend-pg/src/server/routes/sanitizedSchemas.ts b/backend-pg/src/server/routes/sanitizedSchemas.ts index dedc6e3ba..0713e80e4 100644 --- a/backend-pg/src/server/routes/sanitizedSchemas.ts +++ b/backend-pg/src/server/routes/sanitizedSchemas.ts @@ -1,4 +1,6 @@ -import { IntegrationAuthsSchema } from "@app/db/schemas"; +import { z } from "zod"; + +import { IntegrationAuthsSchema, SecretApprovalPoliciesSchema } from "@app/db/schemas"; // sometimes the return data must be santizied to avoid leaking important values // always prefer pick over omit in zod @@ -13,3 +15,14 @@ export const integrationAuthPubSchema = IntegrationAuthsSchema.pick({ createdAt: true, updatedAt: true }); + +export const sapPubSchema = SecretApprovalPoliciesSchema.merge( + z.object({ + environment: z.object({ + id: z.string(), + name: z.string(), + slug: z.string() + }), + projectId: z.string() + }) +); diff --git a/backend-pg/src/services/secret/secret-service.ts b/backend-pg/src/services/secret/secret-service.ts index 02c2ac6df..4460b04bb 100644 --- a/backend-pg/src/services/secret/secret-service.ts +++ b/backend-pg/src/services/secret/secret-service.ts @@ -44,6 +44,23 @@ type TSecretServiceFactoryDep = { export type TSecretServiceFactory = ReturnType; +export 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; +}; + export const secretServiceFactory = ({ secretDal, secretTagDal, @@ -52,23 +69,6 @@ export const secretServiceFactory = ({ 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(); @@ -516,7 +516,8 @@ export const secretServiceFactory = ({ type })) ); - if (secretsWithNewName.length) throw new BadRequestError({ message: "Secret not found" }); + if (secretsWithNewName.length) + throw new BadRequestError({ message: "Secret with new name already exist" }); const secretsGroupedByBlindIndex = secretsToBeUpdated.reduce>( (prev, curr) => { diff --git a/backend-pg/src/services/secret/secret-version-dal.ts b/backend-pg/src/services/secret/secret-version-dal.ts index 3914fa8ae..28174c339 100644 --- a/backend-pg/src/services/secret/secret-version-dal.ts +++ b/backend-pg/src/services/secret/secret-version-dal.ts @@ -1,10 +1,42 @@ import { TDbClient } from "@app/db"; -import { TableName } from "@app/db/schemas"; +import { TSecretVersions, TableName } from "@app/db/schemas"; +import { DatabaseError } from "@app/lib/errors"; import { ormify } from "@app/lib/knex"; +import { Knex } from "knex"; export type TSecretVersionDalFactory = ReturnType; export const secretVersionDalFactory = (db: TDbClient) => { const secretVersionOrm = ormify(db, TableName.SecretVersion); - return secretVersionOrm; + + const findLatestVersionMany = async (folderId: string, secretIds: string[], tx?: Knex) => { + try { + const docs: Array = await (tx || db)( + TableName.SecretVersion + ) + .where("folderId", folderId) + .whereIn(`${TableName.SecretVersion}.secretId`, secretIds) + .join( + (tx || db)(TableName.SecretVersion) + .groupBy("secretId") + .max("version") + .select("secretId") + .as("latestVersion"), + (bd) => { + bd.on(`${TableName.SecretVersion}.secretId`, "latestVersion.secretId").andOn( + `${TableName.SecretVersion}.version`, + "latestVersion.max" + ); + } + ); + return docs.reduce>( + (prev, curr) => ({ ...prev, [curr.secretId]: curr }), + {} + ); + } catch (error) { + throw new DatabaseError({ error, name: "FindLatestVersinMany" }); + } + }; + + return { ...secretVersionOrm, findLatestVersionMany }; }; diff --git a/frontend/src/hooks/api/secretApproval/mutation.tsx b/frontend/src/hooks/api/secretApproval/mutation.tsx index e0a8df95d..86b307cfe 100644 --- a/frontend/src/hooks/api/secretApproval/mutation.tsx +++ b/frontend/src/hooks/api/secretApproval/mutation.tsx @@ -10,7 +10,7 @@ export const useCreateSecretApprovalPolicy = () => { return useMutation<{}, {}, TCreateSecretPolicyDTO>({ mutationFn: async ({ environment, workspaceId, approvals, approvers, secretPath, name }) => { - const { data } = await apiRequest.post("/api/v1/secret-approvals", { + const { data } = await apiRequest.post("/api/ee/v1/secret-approvals", { environment, workspaceId, approvals, @@ -31,7 +31,7 @@ export const useUpdateSecretApprovalPolicy = () => { return useMutation<{}, {}, TUpdateSecretPolicyDTO>({ mutationFn: async ({ id, approvers, approvals, secretPath, name }) => { - const { data } = await apiRequest.patch(`/api/v1/secret-approvals/${id}`, { + const { data } = await apiRequest.patch(`/api/ee/v1/secret-approvals/${id}`, { approvals, approvers, secretPath, @@ -50,7 +50,7 @@ export const useDeleteSecretApprovalPolicy = () => { return useMutation<{}, {}, TDeleteSecretPolicyDTO>({ mutationFn: async ({ id }) => { - const { data } = await apiRequest.delete(`/api/v1/secret-approvals/${id}`); + const { data } = await apiRequest.delete(`/api/ee/v1/secret-approvals/${id}`); return data; }, onSuccess: (_, { workspaceId }) => { diff --git a/frontend/src/hooks/api/secretApproval/queries.tsx b/frontend/src/hooks/api/secretApproval/queries.tsx index ae90ff766..db65737f3 100644 --- a/frontend/src/hooks/api/secretApproval/queries.tsx +++ b/frontend/src/hooks/api/secretApproval/queries.tsx @@ -19,7 +19,7 @@ export const secretApprovalKeys = { const fetchApprovalPolicies = async (workspaceId: string) => { const { data } = await apiRequest.get<{ approvals: TSecretApprovalPolicy[] }>( - "/api/v1/secret-approvals", + "/api/ee/v1/secret-approvals", { params: { workspaceId } } ); return data.approvals; @@ -49,7 +49,7 @@ const fetchApprovalPolicyOfABoard = async ( secretPath: string ) => { const { data } = await apiRequest.get<{ policy: TSecretApprovalPolicy }>( - "/api/v1/secret-approvals/board", + "/api/ee/v1/secret-approvals/board", { params: { workspaceId, environment, secretPath } } ); return data.policy || ""; diff --git a/frontend/src/hooks/api/secretApproval/types.ts b/frontend/src/hooks/api/secretApproval/types.ts index 01a1b64a6..38e8d7ee8 100644 --- a/frontend/src/hooks/api/secretApproval/types.ts +++ b/frontend/src/hooks/api/secretApproval/types.ts @@ -1,8 +1,11 @@ +import { WorkspaceEnv } from "../workspace/types"; + export type TSecretApprovalPolicy = { id: string; workspace: string; name: string; - environment: string; + envId: string; + environment: WorkspaceEnv; secretPath?: string; approvers: string[]; approvals: number; diff --git a/frontend/src/hooks/api/workspace/types.ts b/frontend/src/hooks/api/workspace/types.ts index 77657f9c5..052c1d491 100644 --- a/frontend/src/hooks/api/workspace/types.ts +++ b/frontend/src/hooks/api/workspace/types.ts @@ -11,8 +11,6 @@ export type WorkspaceEnv = { id: string; name: string; slug: string; - isReadDenied: boolean; - isWriteDenied: boolean; }; export type WorkspaceTag = { id: string; name: string; slug: string }; diff --git a/frontend/src/views/SecretApprovalPage/components/SecretApprovalPolicyList/components/SecretApprovalPolicyRow.tsx b/frontend/src/views/SecretApprovalPage/components/SecretApprovalPolicyList/components/SecretApprovalPolicyRow.tsx index d4d85927b..072156b61 100644 --- a/frontend/src/views/SecretApprovalPage/components/SecretApprovalPolicyList/components/SecretApprovalPolicyRow.tsx +++ b/frontend/src/views/SecretApprovalPage/components/SecretApprovalPolicyList/components/SecretApprovalPolicyRow.tsx @@ -41,7 +41,7 @@ export const SecretApprovalPolicyRow = ({ return ( {policy.name} - {policy.environment} + {policy.environment.slug} {policy.secretPath || "*"} ({ resolver: zodResolver(formSchema), - values: editValues + values: editValues ? { ...editValues, environment: editValues.environment.slug } : undefined }); const { currentWorkspace } = useWorkspace(); const { createNotification } = useNotificationContext(); diff --git a/frontend/src/views/SecretApprovalPage/components/SecretApprovalRequest/SecretApprovalRequest.tsx b/frontend/src/views/SecretApprovalPage/components/SecretApprovalRequest/SecretApprovalRequest.tsx index 355c4319d..02af9c1bb 100644 --- a/frontend/src/views/SecretApprovalPage/components/SecretApprovalRequest/SecretApprovalRequest.tsx +++ b/frontend/src/views/SecretApprovalPage/components/SecretApprovalRequest/SecretApprovalRequest.tsx @@ -63,7 +63,7 @@ export const SecretApprovalRequest = () => { (prev, curr) => ({ ...prev, [curr.id]: curr }), {} ); - const myMembershipId = members?.find(({ user }) => user.id === presentUser.id)?.id; + const myMembershipId = members?.find(({ user }) => user.id === presentUser?.id)?.id; const isSecretApprovalScreen = Boolean(selectedApproval); const handleGoBackSecretRequestDetail = () => {