From fc43511f5d250e490e392db83ebd8ab7f0853ce0 Mon Sep 17 00:00:00 2001 From: Akhil Mohan Date: Sat, 30 Sep 2023 23:45:22 +0530 Subject: [PATCH] feat(secret-approval): implemented the base --- backend/src/controllers/v1/index.ts | 5 +- .../v1/secretApprovalRequestsController.ts | 128 +++++++ .../src/controllers/v3/secretsController.ts | 160 +++++++- backend/src/index.ts | 2 + backend/src/models/secretApprovalRequest.ts | 156 ++++++-- backend/src/routes/v1/index.ts | 4 +- .../src/routes/v1/secretApprovalRequest.ts | 31 ++ backend/src/services/SecretApprovalService.ts | 272 +++++++++++++ .../src/validation/secretApprovalRequest.ts | 28 ++ frontend/src/hooks/api/index.tsx | 1 + .../hooks/api/secretApprovalRequest/index.tsx | 2 + .../api/secretApprovalRequest/mutation.tsx | 20 + .../api/secretApprovalRequest/queries.tsx | 144 +++++++ .../hooks/api/secretApprovalRequest/types.ts | 83 ++++ frontend/src/hooks/api/secrets/queries.tsx | 5 +- frontend/src/hooks/api/types.ts | 8 +- .../SecretApprovalPage/SecretApprovalPage.tsx | 4 + .../SecretApprovalPolicyList.tsx | 8 +- .../SecretApprovalRequest.tsx | 97 +++++ .../SecretApprovalRequestChangeItem.tsx | 1 + .../SecretApprovalRequestChanges.tsx | 356 ++++++++++++++++++ .../SecretApprovalRequest/index.tsx | 1 + 22 files changed, 1467 insertions(+), 49 deletions(-) create mode 100644 backend/src/controllers/v1/secretApprovalRequestsController.ts create mode 100644 backend/src/routes/v1/secretApprovalRequest.ts create mode 100644 backend/src/services/SecretApprovalService.ts create mode 100644 backend/src/validation/secretApprovalRequest.ts create mode 100644 frontend/src/hooks/api/secretApprovalRequest/index.tsx create mode 100644 frontend/src/hooks/api/secretApprovalRequest/mutation.tsx create mode 100644 frontend/src/hooks/api/secretApprovalRequest/queries.tsx create mode 100644 frontend/src/hooks/api/secretApprovalRequest/types.ts create mode 100644 frontend/src/views/SecretApprovalPage/components/SecretApprovalRequest/SecretApprovalRequest.tsx create mode 100644 frontend/src/views/SecretApprovalPage/components/SecretApprovalRequest/components/SecretApprovalRequestChangeItem.tsx create mode 100644 frontend/src/views/SecretApprovalPage/components/SecretApprovalRequest/components/SecretApprovalRequestChanges.tsx create mode 100644 frontend/src/views/SecretApprovalPage/components/SecretApprovalRequest/index.tsx diff --git a/backend/src/controllers/v1/index.ts b/backend/src/controllers/v1/index.ts index c2bb1b94d..5569c0bbe 100644 --- a/backend/src/controllers/v1/index.ts +++ b/backend/src/controllers/v1/index.ts @@ -17,7 +17,7 @@ import * as secretScanningController from "./secretScanningController"; import * as webhookController from "./webhookController"; import * as secretImpsController from "./secretImpsController"; import * as secretApprovalPolicyController from "./secretApprovalPolicyController"; - +import * as secretApprovalRequestController from "./secretApprovalRequestsController"; export { authController, botController, @@ -37,5 +37,6 @@ export { secretScanningController, webhookController, secretImpsController, - secretApprovalPolicyController + secretApprovalPolicyController, + secretApprovalRequestController }; diff --git a/backend/src/controllers/v1/secretApprovalRequestsController.ts b/backend/src/controllers/v1/secretApprovalRequestsController.ts new file mode 100644 index 000000000..8672ba5f6 --- /dev/null +++ b/backend/src/controllers/v1/secretApprovalRequestsController.ts @@ -0,0 +1,128 @@ +import { Request, Response } from "express"; +import { getUserProjectPermissions } from "../../ee/services/ProjectRoleService"; +import { validateRequest } from "../../helpers/validation"; +import { Folder } from "../../models"; +import { SecretApprovalRequest } from "../../models/secretApprovalRequest"; +import * as reqValidator from "../../validation/secretApprovalRequest"; +import { getFolderWithPathFromId } from "../../services/FolderService"; +import { BadRequestError, UnauthorizedRequestError } from "../../utils/errors"; +import { ISecretApprovalPolicy } from "../../models/secretApprovalPolicy"; + +export const getSecretApprovalRequests = async (req: Request, res: Response) => { + const { + query: { status, committer, workspaceId, environment, limit, offset } + } = await validateRequest(reqValidator.getSecretApprovalRequests, req); + + const { membership } = await getUserProjectPermissions(req.user._id, workspaceId); + + const query = { + workspace: workspaceId, + environment, + committer, + status, + ...(membership.role !== "admin" + ? { $or: [{ committer: membership.id }, { "policy.approvers": membership.id }] } + : {}) + }; + // to strip of undefined in query we use es6 spread to ignore those fields + Object.entries(query).forEach( + ([key, value]) => value === undefined && delete query[key as keyof typeof query] + ); + const approvalRequests = await SecretApprovalRequest.find(query) + .limit(limit) + .skip(offset) + .populate("policy") + .lean(); + if (!approvalRequests.length) return res.send({ requests: [] }); + + const unqiueEnvs = environment ?? { + $in: [...new Set(approvalRequests.map(({ environment }) => environment))] + }; + const approvalRootFolders = await Folder.find({ + workspace: workspaceId, + environment: unqiueEnvs + }).lean(); + + const formatedApprovals = approvalRequests.map((el) => { + let secretPath = "/"; + const folders = approvalRootFolders.find(({ environment }) => environment === el.environment); + if (folders) { + secretPath = getFolderWithPathFromId(folders?.nodes, el.folderId)?.folderPath || "/"; + } + return { ...el, secretPath }; + }); + + return res.send({ + approvals: formatedApprovals + }); +}; + +export const getSecretApprovalRequestDetails = async (req: Request, res: Response) => { + const { + params: { id } + } = await validateRequest(reqValidator.getSecretApprovalRequestDetails, req); + const secretApprovalRequest = await SecretApprovalRequest.findById(id) + .populate("policy") + .populate({ + path: "commits.secret", + populate: { + path: "tags" + } + }) + .populate("commits.newVersion.tags"); + if (!secretApprovalRequest) + throw BadRequestError({ message: "Secret approval request not found" }); + + const { membership } = await getUserProjectPermissions( + req.user._id, + secretApprovalRequest.workspace.toString() + ); + // allow to fetch only if its admin or is the committer or approver + if ( + membership.role !== "admin" && + secretApprovalRequest.committer !== membership.id && + secretApprovalRequest.reviewers.find(({ member }) => member === membership.id) + ) { + throw UnauthorizedRequestError({ message: "User has no access" }); + } + + return res.send({ + approval: secretApprovalRequest + }); +}; + +export const updateSecretApprovalRequestStatus = async (req: Request, res: Response) => { + const { + body: { status }, + params: { id } + } = await validateRequest(reqValidator.updateSecretApprovalRequestStatus, req); + const secretApprovalRequest = await SecretApprovalRequest.findById(id).populate<{ + policy: ISecretApprovalPolicy; + }>("policy"); + if (!secretApprovalRequest) + throw BadRequestError({ message: "Secret approval request not found" }); + + const { membership } = await getUserProjectPermissions( + req.user._id, + secretApprovalRequest.workspace.toString() + ); + if ( + membership.role !== "admin" && + secretApprovalRequest.committer !== membership.id && + !secretApprovalRequest.policy.approvers.find((approverId) => approverId === membership.id) + ) { + throw UnauthorizedRequestError({ message: "User has no access" }); + } + + const reviewerPos = secretApprovalRequest.reviewers.findIndex( + ({ member }) => member.toString() === membership._id.toString() + ); + if (reviewerPos !== -1) { + secretApprovalRequest.reviewers[reviewerPos].status = status; + } else { + secretApprovalRequest.reviewers.push({ member: membership._id, status }); + } + await secretApprovalRequest.save(); + + return res.send({ status }); +}; diff --git a/backend/src/controllers/v3/secretsController.ts b/backend/src/controllers/v3/secretsController.ts index 482e5baa6..84a324afa 100644 --- a/backend/src/controllers/v3/secretsController.ts +++ b/backend/src/controllers/v3/secretsController.ts @@ -12,6 +12,7 @@ import { encryptSymmetric128BitHexKeyUTF8 } from "../../utils/crypto"; import { getAllImportedSecrets } from "../../services/SecretImportService"; import { Folder, + IMembership, IServiceTokenData, IServiceTokenDataV3 } from "../../models"; @@ -49,7 +50,7 @@ const checkSecretsPermission = async ({ environment: string; secretPath: string; secretAction: ProjectPermissionActions; // CRUD -}): Promise<(env: string, secPath: string) => boolean> => { +}): Promise<{authVerifier:(env: string, secPath: string) => boolean,membership?:Omit & {customRole: IRole}}> => { let STV2RequiredPermissions = []; let STV3RequiredPermissions: Permission[] = []; @@ -75,19 +76,19 @@ const checkSecretsPermission = async ({ switch (authData.actor.type) { case ActorType.USER: { - const { permission } = await getUserProjectPermissions(authData.actor.metadata.userId, workspaceId); + const { permission,membership } = await getUserProjectPermissions(authData.actor.metadata.userId, workspaceId); ForbiddenError.from(permission).throwUnlessCan( secretAction, subject(ProjectPermissionSub.Secrets, { environment, secretPath }) ); - return (env: string, secPath: string) => + return {authVerifier: (env: string, secPath: string) => permission.can( secretAction, subject(ProjectPermissionSub.Secrets, { environment: env, secretPath: secPath }) - ); + ),membership}; } case ActorType.SERVICE: { await validateServiceTokenDataClientForWorkspace({ @@ -97,7 +98,7 @@ const checkSecretsPermission = async ({ secretPath, requiredPermissions: STV2RequiredPermissions }); - return () => true; + return {authVerifier:() => true}; } case ActorType.SERVICE_V3: { await validateServiceTokenDataV3ClientForWorkspace({ @@ -108,19 +109,25 @@ const checkSecretsPermission = async ({ secretPath, requiredPermissions: STV3RequiredPermissions }); - return (env: string, secPath: string) => + return {authVerifier: (env: string, secPath: string) => isValidScopeV3({ authPayload: authData.authPayload as IServiceTokenDataV3, environment: env, secretPath: secPath, requiredPermissions: STV3RequiredPermissions - }); + })}; } default: { throw UnauthorizedRequestError(); } } } +import { + generateSecretApprovalRequest, + getSecretPolicyOfBoard +} from "../../services/SecretApprovalService"; +import { CommitType } from "../../models/secretApprovalRequest"; +import { IRole } from "../../ee/models/role"; /** * Return secrets for workspace with id [workspaceId] and environment @@ -160,7 +167,7 @@ export const getSecretsRaw = async (req: Request, res: Response) => { if (!environment || !workspaceId) throw BadRequestError({ message: "Missing environment or workspace id" }); - const permissionCheckFn = await checkSecretsPermission({ + const {authVerifier:permissionCheckFn} = await checkSecretsPermission({ authData: req.authData, workspaceId, environment, @@ -475,7 +482,7 @@ export const getSecrets = async (req: Request, res: Response) => { secretPath = getFolderWithPathFromId(folder.nodes, folderId).folderPath; } - const permissionCheckFn = await checkSecretsPermission({ + const {authVerifier:permissionCheckFn} = await checkSecretsPermission({ authData: req.authData, workspaceId, environment, @@ -580,7 +587,7 @@ export const createSecret = async (req: Request, res: Response) => { params: { secretName } } = await validateRequest(reqValidator.CreateSecretV3, req); - await checkSecretsPermission({ + const {membership} = await checkSecretsPermission({ authData: req.authData, workspaceId, environment, @@ -588,6 +595,35 @@ export const createSecret = async (req: Request, res: Response) => { secretAction: ProjectPermissionActions.Create }); + const secretApprovalPolicy = await getSecretPolicyOfBoard(workspaceId, environment, secretPath); + if (secretApprovalPolicy && membership) { + const secretApprovalRequest = await generateSecretApprovalRequest({ + workspaceId, + environment, + secretPath, + policy: secretApprovalPolicy, + commiterMembershipId: membership._id.toString(), + data: { + [CommitType.CREATE]: [ + { + secretName, + secretValueCiphertext, + secretValueIV, + secretValueTag, + secretCommentIV, + secretCommentTag, + secretCommentCiphertext, + skipMultilineEncoding, + secretKeyTag, + secretKeyCiphertext, + secretKeyIV + } + ] + } + }); + return res.send({ approval: secretApprovalRequest }); + } + const secret = await SecretService.createSecret({ secretName, workspaceId: new Types.ObjectId(workspaceId), @@ -656,7 +692,7 @@ export const updateSecretByName = async (req: Request, res: Response) => { throw BadRequestError({ message: "Missing encrypted key" }); } - await checkSecretsPermission({ + const {membership} = await checkSecretsPermission({ authData: req.authData, workspaceId, environment, @@ -664,6 +700,36 @@ export const updateSecretByName = async (req: Request, res: Response) => { secretAction: ProjectPermissionActions.Edit }); + const secretApprovalPolicy = await getSecretPolicyOfBoard(workspaceId, environment, secretPath); + if (secretApprovalPolicy && membership) { + const secretApprovalRequest = await generateSecretApprovalRequest({ + workspaceId, + environment, + secretPath, + policy: secretApprovalPolicy, + commiterMembershipId: membership._id.toString(), + data: { + [CommitType.UPDATE]: [ + { + secretName, + secretValueCiphertext, + secretValueIV, + secretValueTag, + tags, + secretCommentIV, + secretCommentTag, + secretCommentCiphertext, + skipMultilineEncoding, + secretKeyTag, + secretKeyCiphertext, + secretKeyIV + } + ] + } + }); + return res.send({ approval: secretApprovalRequest }); + } + const secret = await SecretService.updateSecret({ secretName, workspaceId: new Types.ObjectId(workspaceId), @@ -709,7 +775,7 @@ export const deleteSecretByName = async (req: Request, res: Response) => { params: { secretName } } = await validateRequest(reqValidator.DeleteSecretByNameV3, req); - await checkSecretsPermission({ + const {membership} = await checkSecretsPermission({ authData: req.authData, workspaceId, environment, @@ -717,6 +783,25 @@ export const deleteSecretByName = async (req: Request, res: Response) => { secretAction: ProjectPermissionActions.Delete }); + const secretApprovalPolicy = await getSecretPolicyOfBoard(workspaceId, environment, secretPath); + if (secretApprovalPolicy && membership) { + const secretApprovalRequest = await generateSecretApprovalRequest({ + workspaceId, + environment, + secretPath, + policy: secretApprovalPolicy, + commiterMembershipId: membership._id.toString(), + data: { + [CommitType.DELETE]: [ + { + secretName + } + ] + } + }); + return res.send({ approval: secretApprovalRequest }); + } + const { secret } = await SecretService.deleteSecret({ secretName, workspaceId: new Types.ObjectId(workspaceId), @@ -744,7 +829,7 @@ export const createSecretByNameBatch = async (req: Request, res: Response) => { body: { secrets, secretPath, environment, workspaceId } } = await validateRequest(reqValidator.CreateSecretByNameBatchV3, req); - await checkSecretsPermission({ + const {membership} = await checkSecretsPermission({ authData: req.authData, workspaceId, environment, @@ -752,6 +837,21 @@ export const createSecretByNameBatch = async (req: Request, res: Response) => { secretAction: ProjectPermissionActions.Create }); + const secretApprovalPolicy = await getSecretPolicyOfBoard(workspaceId, environment, secretPath); + if (secretApprovalPolicy && membership) { + const secretApprovalRequest = await generateSecretApprovalRequest({ + workspaceId, + environment, + secretPath, + policy: secretApprovalPolicy, + commiterMembershipId: membership._id.toString(), + data: { + [CommitType.CREATE]: secrets + } + }); + return res.send({ approval: secretApprovalRequest }); + } + const createdSecrets = await SecretService.createSecretBatch({ secretPath, environment, @@ -770,7 +870,7 @@ export const updateSecretByNameBatch = async (req: Request, res: Response) => { body: { secrets, secretPath, environment, workspaceId } } = await validateRequest(reqValidator.UpdateSecretByNameBatchV3, req); - await checkSecretsPermission({ + const {membership} = await checkSecretsPermission({ authData: req.authData, workspaceId, environment, @@ -778,6 +878,21 @@ export const updateSecretByNameBatch = async (req: Request, res: Response) => { secretAction: ProjectPermissionActions.Edit }); + const secretApprovalPolicy = await getSecretPolicyOfBoard(workspaceId, environment, secretPath); + if (secretApprovalPolicy && membership) { + const secretApprovalRequest = await generateSecretApprovalRequest({ + workspaceId, + environment, + secretPath, + policy: secretApprovalPolicy, + commiterMembershipId: membership._id.toString(), + data: { + [CommitType.UPDATE]: secrets + } + }); + return res.send({ approval: secretApprovalRequest }); + } + const updatedSecrets = await SecretService.updateSecretBatch({ secretPath, environment, @@ -796,13 +911,28 @@ export const deleteSecretByNameBatch = async (req: Request, res: Response) => { body: { secrets, secretPath, environment, workspaceId } } = await validateRequest(reqValidator.DeleteSecretByNameBatchV3, req); - await checkSecretsPermission({ + const {membership} = await checkSecretsPermission({ authData: req.authData, workspaceId, environment, secretPath, secretAction: ProjectPermissionActions.Delete }); + + const secretApprovalPolicy = await getSecretPolicyOfBoard(workspaceId, environment, secretPath); + if (secretApprovalPolicy && membership) { + const secretApprovalRequest = await generateSecretApprovalRequest({ + workspaceId, + environment, + secretPath, + policy: secretApprovalPolicy, + commiterMembershipId: membership._id.toString(), + data: { + [CommitType.DELETE]: secrets + } + }); + return res.send({ approval: secretApprovalRequest }); + } const deletedSecrets = await SecretService.deleteSecretBatch({ secretPath, diff --git a/backend/src/index.ts b/backend/src/index.ts index 602da13ec..c00512c08 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -43,6 +43,7 @@ import { password as v1PasswordRouter, sso as v1SSORouter, secretApprovalPolicy as v1SecretApprovalPolicy, + secretApprovalRequest as v1SecretApprovalRequest, secretImps as v1SecretImpsRouter, secret as v1SecretRouter, secretsFolder as v1SecretsFolder, @@ -183,6 +184,7 @@ const main = async () => { app.use("/api/v1/roles", v1RoleRouter); app.use("/api/v1/secret-approvals", v1SecretApprovalPolicy); app.use("/api/v1/sso", v1SSORouter); + app.use("/api/v1/secret-approval-requests", v1SecretApprovalRequest); // v2 routes (improvements) app.use("/api/v2/signup", v2SignupRouter); diff --git a/backend/src/models/secretApprovalRequest.ts b/backend/src/models/secretApprovalRequest.ts index a145b5773..58ba38a7e 100644 --- a/backend/src/models/secretApprovalRequest.ts +++ b/backend/src/models/secretApprovalRequest.ts @@ -1,58 +1,162 @@ import { Schema, Types, model } from "mongoose"; -import { ISecretVersion, SecretVersion } from "../ee/models/secretVersion"; +import { ALGORITHM_AES_256_GCM, ENCODING_SCHEME_BASE64, ENCODING_SCHEME_UTF8 } from "../variables"; -enum ApprovalStatus { +export enum ApprovalStatus { PENDING = "pending", APPROVED = "approved", REJECTED = "rejected" } -enum CommitType { +export enum CommitType { DELETE = "delete", UPDATE = "update", CREATE = "create" } +export interface ISecretApprovalSecChange { + _id: Types.ObjectId; + version: number; + secretBlindIndex?: string; + secretKeyCiphertext: string; + secretKeyIV: string; + secretKeyTag: string; + secretValueCiphertext: string; + secretValueIV: string; + secretValueTag: string; + secretCommentIV?: string; + secretCommentTag?: string; + secretCommentCiphertext?: string; + skipMultilineEncoding?: boolean; + algorithm?: "aes-256-gcm"; + keyEncoding?: "utf8" | "base64"; + tags?: string[]; +} + export interface ISecretApprovalRequest { _id: Types.ObjectId; committer: Types.ObjectId; - approvers: { + reviewers: { member: Types.ObjectId; status: ApprovalStatus; }[]; - approvals: number; + workspace: Types.ObjectId; + environment: string; + folderId: string; hasMerged: boolean; - status: ApprovalStatus; - commits: { - secretVersion: Types.ObjectId; - newVersion: ISecretVersion; - op: CommitType; - }[]; + status: "open" | "close"; + policy: Types.ObjectId; + commits: Array< + | { + newVersion: ISecretApprovalSecChange; + op: CommitType.CREATE; + } + | { + secret: Types.ObjectId; + newVersion: Partial; + op: CommitType.UPDATE; + } + | { + secret: Types.ObjectId; + op: CommitType.DELETE; + } + >; } +const secretApprovalSecretChangeSchema = new Schema({ + version: { + type: Number, + default: 1, + required: true + }, + secretBlindIndex: { + type: String, + select: false + }, + secretKeyCiphertext: { + type: String, + required: true + }, + secretKeyIV: { + type: String, // symmetric + required: true + }, + secretKeyTag: { + type: String, // symmetric + required: true + }, + secretValueCiphertext: { + type: String, + required: true + }, + secretValueIV: { + type: String, // symmetric + required: true + }, + secretValueTag: { + type: String, // symmetric + required: true + }, + skipMultilineEncoding: { + type: Boolean, + required: false + }, + algorithm: { + // the encryption algorithm used + type: String, + enum: [ALGORITHM_AES_256_GCM], + required: true, + default: ALGORITHM_AES_256_GCM + }, + keyEncoding: { + type: String, + enum: [ENCODING_SCHEME_UTF8, ENCODING_SCHEME_BASE64], + required: true, + default: ENCODING_SCHEME_UTF8 + }, + tags: { + ref: "Tag", + type: [Schema.Types.ObjectId], + default: [] + } +}); + const secretApprovalRequestSchema = new Schema( { - approvers: [ - { - member: { - // user associated with the personal secret - type: Schema.Types.ObjectId, - ref: "Membership" - }, - status: { type: String, enum: ApprovalStatus, default: ApprovalStatus.PENDING } - } - ], - approvals: { - type: Number, + workspace: { + type: Schema.Types.ObjectId, + ref: "Workspace", required: true }, + environment: { + type: String, + required: true + }, + folderId: { + type: String, + required: true, + default: "root" + }, + reviewers: { + type: [ + { + member: { + // user associated with the personal secret + type: Schema.Types.ObjectId, + ref: "Membership" + }, + status: { type: String, enum: ApprovalStatus, default: ApprovalStatus.PENDING } + } + ], + default: [] + }, + policy: { type: Schema.Types.ObjectId, ref: "SecretApprovalPolicy" }, hasMerged: { type: Boolean, default: false }, - status: { type: String, enum: ApprovalStatus, default: ApprovalStatus.PENDING }, + status: { type: String, enum: ["close", "open"], default: "open" }, committer: { type: Schema.Types.ObjectId, ref: "Membership" }, commits: [ { - secretVersion: { type: Types.ObjectId, ref: "SecretVersion" }, - newVersion: SecretVersion, + secret: { type: Types.ObjectId, ref: "Secret" }, + newVersion: secretApprovalSecretChangeSchema, op: { type: String, enum: [CommitType], required: true } } ] diff --git a/backend/src/routes/v1/index.ts b/backend/src/routes/v1/index.ts index 50275d00f..252971d27 100644 --- a/backend/src/routes/v1/index.ts +++ b/backend/src/routes/v1/index.ts @@ -19,6 +19,7 @@ import secretsFolder from "./secretsFolder"; import webhooks from "./webhook"; import secretImps from "./secretImps"; import secretApprovalPolicy from "./secretApprovalPolicy"; +import secretApprovalRequest from "./secretApprovalRequest"; export { signup, @@ -41,5 +42,6 @@ export { webhooks, secretImps, sso, - secretApprovalPolicy + secretApprovalPolicy, + secretApprovalRequest }; diff --git a/backend/src/routes/v1/secretApprovalRequest.ts b/backend/src/routes/v1/secretApprovalRequest.ts new file mode 100644 index 000000000..7e3ed02e1 --- /dev/null +++ b/backend/src/routes/v1/secretApprovalRequest.ts @@ -0,0 +1,31 @@ +import express from "express"; +const router = express.Router(); +import { requireAuth } from "../../middleware"; +import { secretApprovalRequestController } from "../../controllers/v1"; +import { AuthMode } from "../../variables"; + +router.get( + "/", + requireAuth({ + acceptedAuthModes: [AuthMode.JWT] + }), + secretApprovalRequestController.getSecretApprovalRequests +); + +router.get( + "/:id", + requireAuth({ + acceptedAuthModes: [AuthMode.JWT] + }), + secretApprovalRequestController.getSecretApprovalRequestDetails +); + +router.post( + "/:id", + requireAuth({ + acceptedAuthModes: [AuthMode.JWT] + }), + secretApprovalRequestController.updateSecretApprovalRequestStatus +); + +export default router; diff --git a/backend/src/services/SecretApprovalService.ts b/backend/src/services/SecretApprovalService.ts new file mode 100644 index 000000000..2c27ff777 --- /dev/null +++ b/backend/src/services/SecretApprovalService.ts @@ -0,0 +1,272 @@ +import picomatch from "picomatch"; +import { Types } from "mongoose"; +import { + containsGlobPatterns, + generateSecretBlindIndexWithSaltHelper, + getSecretBlindIndexSaltHelper +} from "../helpers/secrets"; +import { Folder, ISecret, Secret } from "../models"; +import { ISecretApprovalPolicy, SecretApprovalPolicy } from "../models/secretApprovalPolicy"; +import { + CommitType, + ISecretApprovalRequest, + ISecretApprovalSecChange, + SecretApprovalRequest +} from "../models/secretApprovalRequest"; +import { BadRequestError } from "../utils/errors"; +import { getFolderByPath } from "./FolderService"; +import { SECRET_SHARED } from "../variables"; + +// if glob pattern score is 1, if not exist score is 0 and if its not both then its exact path meaning score 2 +const getPolicyScore = (policy: ISecretApprovalPolicy) => + policy.secretPath ? (containsGlobPatterns(policy.secretPath) ? 1 : 2) : 0; + +// this will fetch the policy that gets priority for an environment and secret path +export const getSecretPolicyOfBoard = async ( + workspaceId: string, + environment: string, + secretPath: string +) => { + const policies = await SecretApprovalPolicy.find({ workspace: workspaceId, environment }); + if (!policies) 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(a) - getPolicyScore(b) + ); + const finalPolicy = policiesByPriority.shift(); + return finalPolicy; +}; + +type TApprovalCreateSecret = Omit & { + secretName: string; +}; +type TApprovalUpdateSecret = Partial> & { + secretName: string; + newSecretName?: string; +}; +type TGenerateSecretApprovalRequestArg = { + workspaceId: string; + environment: string; + secretPath: string; + policy: ISecretApprovalPolicy; + data: { + [CommitType.CREATE]?: TApprovalCreateSecret[]; + [CommitType.UPDATE]?: TApprovalUpdateSecret[]; + [CommitType.DELETE]?: { secretName: string }[]; + }; + commiterMembershipId: string; +}; + +export const generateSecretApprovalRequest = async ({ + workspaceId, + environment, + secretPath, + policy, + data, + commiterMembershipId +}: TGenerateSecretApprovalRequestArg) => { + // calculate folder id from secret path + let folderId = "root"; + const rootFolder = await Folder.findOne({ workspace: workspaceId, environment }); + if (!rootFolder && secretPath !== "/") throw BadRequestError({ message: "Folder not found" }); + if (rootFolder) { + const folder = getFolderByPath(rootFolder.nodes, secretPath); + if (!folder) throw BadRequestError({ message: "Folder not found" }); + folderId = folder.id; + } + + // generate secret blindIndexes + const salt = await getSecretBlindIndexSaltHelper({ + workspaceId: new Types.ObjectId(workspaceId) + }); + const commits: ISecretApprovalRequest["commits"] = []; + + // ----- + // for created secret approval change + const createdSecret = data[CommitType.CREATE]; + if (createdSecret && createdSecret?.length) { + // validation checks whether secret exists for creation + const secretBlindIndexes = await Promise.all( + createdSecret.map(({ secretName }) => + generateSecretBlindIndexWithSaltHelper({ + secretName, + salt + }) + ) + ).then((blindIndexes) => + blindIndexes.reduce>((prev, curr, i) => { + prev[createdSecret[i].secretName] = curr; + return prev; + }, {}) + ); + // check created secret exists + const exists = await Secret.exists({ + workspace: new Types.ObjectId(workspaceId), + folder: folderId, + environment + }) + .or( + createdSecret.map(({ secretName }) => ({ + secretBlindIndex: secretBlindIndexes[secretName], + type: SECRET_SHARED + })) + ) + .exec(); + if (exists) throw BadRequestError({ message: "Secrets already exist" }); + commits.push( + ...createdSecret.map((el) => ({ + op: CommitType.CREATE as const, + newVersion: { + ...el, + version: 0, + _id: new Types.ObjectId(), + secretBlindIndex: secretBlindIndexes[el.secretName] + } + })) + ); + } + + // ---- + // updated secrets approval change + const updatedSecret = data[CommitType.UPDATE]; + if (updatedSecret && updatedSecret?.length) { + // validation checks whether secret doesn't exists for update + const secretBlindIndexes = await Promise.all( + updatedSecret.map(({ secretName }) => + generateSecretBlindIndexWithSaltHelper({ + secretName, + salt + }) + ) + ).then((blindIndexes) => + blindIndexes.reduce>((prev, curr, i) => { + prev[updatedSecret[i].secretName] = curr; + return prev; + }, {}) + ); + // check update secret exists + const secretsToBeUpdated = await Secret.find({ + workspace: new Types.ObjectId(workspaceId), + folder: folderId, + environment + }) + .select("+secretBlindIndex") + .or( + updatedSecret.map(({ secretName }) => ({ + secretBlindIndex: secretBlindIndexes[secretName], + type: SECRET_SHARED + })) + ) + .lean() + .exec(); + if (secretsToBeUpdated.length !== updatedSecret.length) + throw BadRequestError({ message: "Secrets already exist" }); + // finally check updating blindindex exist + const nameUpdatedSecrets = updatedSecret.filter(({ newSecretName }) => Boolean(newSecretName)); + const newSecretBlindIndexes = await Promise.all( + nameUpdatedSecrets.map(({ newSecretName }) => + generateSecretBlindIndexWithSaltHelper({ + secretName: newSecretName as string, + salt + }) + ) + ).then((blindIndexes) => + blindIndexes.reduce>((prev, curr, i) => { + prev[nameUpdatedSecrets[i].secretName] = curr; + return prev; + }, {}) + ); + const doesAnySecretExistWithNewIndex = await Secret.find({ + workspace: new Types.ObjectId(workspaceId), + folder: folderId, + environment, + secretBlindIndex: { $in: Object.values(newSecretBlindIndexes) } + }); + if (doesAnySecretExistWithNewIndex) + throw BadRequestError({ message: "Secret with new name already exist" }); + + commits.push( + ...updatedSecret.map((el) => { + const oldSecret = secretsToBeUpdated.find( + (sec) => sec?.secretBlindIndex === secretBlindIndexes[el.secretName] + ); + if (!oldSecret) throw BadRequestError({ message: "Secret not found" }); + + return { + op: CommitType.UPDATE as const, + secret: oldSecret._id, + newVersion: { + ...el, + secretBlindIndex: newSecretBlindIndexes?.[el.secretName], + _id: new Types.ObjectId(), + version: oldSecret.version || 1 + } + }; + }) + ); + } + + // ----- + // deleted secrets + const deletedSecrets = data[CommitType.DELETE]; + if (deletedSecrets && deletedSecrets.length) { + const secretBlindIndexes = await Promise.all( + deletedSecrets.map(({ secretName }) => + generateSecretBlindIndexWithSaltHelper({ + secretName, + salt + }) + ) + ).then((blindIndexes) => + blindIndexes.reduce>((prev, curr, i) => { + prev[deletedSecrets[i].secretName] = curr; + return prev; + }, {}) + ); + + const secretsToDelete = await Secret.find({ + workspace: new Types.ObjectId(workspaceId), + folder: folderId, + environment + }) + .or( + deletedSecrets.map(({ secretName }) => ({ + secretBlindIndex: secretBlindIndexes[secretName], + type: SECRET_SHARED + })) + ) + .select({ secretBlindIndexes: 1 }) + .lean() + .exec(); + if (secretsToDelete.length !== deletedSecrets.length) + throw BadRequestError({ message: "Deleted secrets not found" }); + + commits.push( + ...deletedSecrets.map((el) => ({ + op: CommitType.DELETE as const, + secret: ( + secretsToDelete.find( + (sec) => sec?.secretBlindIndex === secretBlindIndexes[el.secretName] + ) as ISecret + )._id + })) + ); + } + + const secretApprovalRequest = new SecretApprovalRequest({ + workspace: workspaceId, + environment, + folderId, + policy, + commits, + committer: commiterMembershipId + }); + await secretApprovalRequest.save(); + return secretApprovalRequest; +}; diff --git a/backend/src/validation/secretApprovalRequest.ts b/backend/src/validation/secretApprovalRequest.ts new file mode 100644 index 000000000..df339a84a --- /dev/null +++ b/backend/src/validation/secretApprovalRequest.ts @@ -0,0 +1,28 @@ +import { z } from "zod"; +import { ApprovalStatus } from "../models/secretApprovalRequest"; + +export const getSecretApprovalRequests = z.object({ + query: z.object({ + workspaceId: z.string().trim(), + environment: z.string().trim().optional(), + committer: z.string().trim().optional(), + status: z.string().trim().optional(), + limit: z.coerce.number().default(20), + offset: z.coerce.number().default(0) + }) +}); + +export const getSecretApprovalRequestDetails = z.object({ + params: z.object({ + id: z.string().trim() + }) +}); + +export const updateSecretApprovalRequestStatus = z.object({ + body: z.object({ + status: z.enum([ApprovalStatus.APPROVED, ApprovalStatus.REJECTED]) + }), + params: z.object({ + id: z.string().trim() + }) +}); diff --git a/frontend/src/hooks/api/index.tsx b/frontend/src/hooks/api/index.tsx index 05de5a8d5..b065b23a1 100644 --- a/frontend/src/hooks/api/index.tsx +++ b/frontend/src/hooks/api/index.tsx @@ -8,6 +8,7 @@ export * from "./keys"; export * from "./organization"; export * from "./roles"; export * from "./secretApproval"; +export * from "./secretApprovalRequest"; export * from "./secretFolders"; export * from "./secretImports"; export * from "./secrets"; diff --git a/frontend/src/hooks/api/secretApprovalRequest/index.tsx b/frontend/src/hooks/api/secretApprovalRequest/index.tsx new file mode 100644 index 000000000..cf677c453 --- /dev/null +++ b/frontend/src/hooks/api/secretApprovalRequest/index.tsx @@ -0,0 +1,2 @@ +export { useUpdateSecretApprovalRequestStatus } from "./mutation"; +export { useGetSecretApprovalRequestDetails, useGetSecretApprovalRequests } from "./queries"; diff --git a/frontend/src/hooks/api/secretApprovalRequest/mutation.tsx b/frontend/src/hooks/api/secretApprovalRequest/mutation.tsx new file mode 100644 index 000000000..877c97922 --- /dev/null +++ b/frontend/src/hooks/api/secretApprovalRequest/mutation.tsx @@ -0,0 +1,20 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; + +import { apiRequest } from "@app/config/request"; + +import { secretApprovalRequestKeys } from "./queries"; +import { TUpdateSecretApprovalRequestStatusDTO } from "./types"; + +export const useUpdateSecretApprovalRequestStatus = () => { + const queryClient = useQueryClient(); + + return useMutation<{}, {}, TUpdateSecretApprovalRequestStatusDTO>({ + mutationFn: async ({ id, status }) => { + const { data } = await apiRequest.post(`/api/v1/secret-approval-requests/${id}`, { status }); + return data; + }, + onSuccess: (_, { id }) => { + queryClient.invalidateQueries(secretApprovalRequestKeys.detail({ id })); + } + }); +}; diff --git a/frontend/src/hooks/api/secretApprovalRequest/queries.tsx b/frontend/src/hooks/api/secretApprovalRequest/queries.tsx new file mode 100644 index 000000000..63f923071 --- /dev/null +++ b/frontend/src/hooks/api/secretApprovalRequest/queries.tsx @@ -0,0 +1,144 @@ +import { useQuery, UseQueryOptions } from "@tanstack/react-query"; + +import { + decryptAssymmetric, + decryptSymmetric +} from "@app/components/utilities/cryptography/crypto"; +import { apiRequest } from "@app/config/request"; + +import { UserWsKeyPair } from "../keys/types"; +import { decryptSecrets } from "../secrets/queries"; +import { DecryptedSecret } from "../secrets/types"; +import { + TGetSecretApprovalRequestDetails, + TGetSecretApprovalRequestList, + TSecretApprovalRequest, + TSecretApprovalSecChange, + TSecretApprovalSecChangeData +} from "./types"; + +export const secretApprovalRequestKeys = { + list: ({ workspaceId, environment }: TGetSecretApprovalRequestList) => + [{ workspaceId, environment }, "secret-approval-requests"] as const, + detail: ({ id }: Omit) => + [{ id }, "secret-approval-request-detail"] as const +}; + +export const decryptSecretApprovalSecret = ( + encSecret: TSecretApprovalSecChangeData, + decryptFileKey: UserWsKeyPair +) => { + const PRIVATE_KEY = localStorage.getItem("PRIVATE_KEY") as string; + const key = decryptAssymmetric({ + ciphertext: decryptFileKey.encryptedKey, + nonce: decryptFileKey.nonce, + publicKey: decryptFileKey.sender.publicKey, + privateKey: PRIVATE_KEY + }); + + const secretKey = decryptSymmetric({ + ciphertext: encSecret.secretKeyCiphertext, + iv: encSecret.secretKeyIV, + tag: encSecret.secretKeyTag, + key + }); + + const secretValue = decryptSymmetric({ + ciphertext: encSecret.secretValueCiphertext, + iv: encSecret.secretValueIV, + tag: encSecret.secretValueTag, + key + }); + + const secretComment = decryptSymmetric({ + ciphertext: encSecret.secretCommentCiphertext, + iv: encSecret.secretCommentIV, + tag: encSecret.secretCommentTag, + key + }); + return { + _id: encSecret._id, + version: encSecret.version, + secretKey, + secretValue, + secretComment, + tags: encSecret.tags + }; +}; + +const fetchSecretApprovalRequestList = async ({ + workspaceId, + environment +}: TGetSecretApprovalRequestList) => { + const { data } = await apiRequest.get<{ approvals: TSecretApprovalRequest[] }>( + "/api/v1/secret-approval-requests", + { + params: { + workspaceId, + environment + } + } + ); + + return data.approvals; +}; + +export const useGetSecretApprovalRequests = ({ + workspaceId, + environment, + options = {} +}: TGetSecretApprovalRequestList & { + options?: Omit< + UseQueryOptions< + TSecretApprovalRequest[], + unknown, + TSecretApprovalRequest[], + ReturnType + >, + "queryKey" | "queryFn" + >; +}) => + useQuery({ + queryKey: secretApprovalRequestKeys.list({ workspaceId, environment }), + queryFn: () => fetchSecretApprovalRequestList({ workspaceId, environment }), + enabled: Boolean(workspaceId) && (options?.enabled ?? true) + }); + +const fetchSecretApprovalRequestDetails = async ({ + id +}: Omit) => { + const { data } = await apiRequest.get<{ approval: TSecretApprovalRequest }>( + `/api/v1/secret-approval-requests/${id}` + ); + + return data.approval; +}; + +export const useGetSecretApprovalRequestDetails = ({ + id, + decryptKey, + options = {} +}: TGetSecretApprovalRequestDetails & { + options?: Omit< + UseQueryOptions< + TSecretApprovalRequest, + unknown, + TSecretApprovalRequest, + ReturnType + >, + "queryKey" | "queryFn" + >; +}) => + useQuery({ + queryKey: secretApprovalRequestKeys.detail({ id }), + queryFn: () => fetchSecretApprovalRequestDetails({ id }), + select: (data) => ({ + ...data, + commits: data.commits.map(({ secret, op, newVersion }) => ({ + op, + secret: secret ? decryptSecrets([secret], decryptKey)[0] : undefined, + newVersion: newVersion ? decryptSecretApprovalSecret(newVersion, decryptKey) : undefined + })) + }), + enabled: Boolean(id && decryptKey) && (options?.enabled ?? true) + }); diff --git a/frontend/src/hooks/api/secretApprovalRequest/types.ts b/frontend/src/hooks/api/secretApprovalRequest/types.ts new file mode 100644 index 000000000..377df3e1f --- /dev/null +++ b/frontend/src/hooks/api/secretApprovalRequest/types.ts @@ -0,0 +1,83 @@ +import { UserWsKeyPair } from "../keys/types"; +import { TSecretApprovalPolicy } from "../secretApproval/types"; +import { EncryptedSecret } from "../secrets/types"; +import { WsTag } from "../tags/types"; + +export enum ApprovalStatus { + PENDING = "pending", + APPROVED = "approved", + REJECTED = "rejected" +} + +export enum CommitType { + DELETE = "delete", + UPDATE = "update", + CREATE = "create" +} + +export type TSecretApprovalSecChangeData = { + _id: string; + version: number; + secretKeyCiphertext: string; + secretKeyIV: string; + secretKeyTag: string; + secretValueCiphertext: string; + secretValueIV: string; + secretValueTag: string; + secretCommentIV: string; + secretCommentTag: string; + secretCommentCiphertext: string; + skipMultilineEncoding?: boolean; + algorithm: "aes-256-gcm"; + keyEncoding: "utf8" | "base64"; + tags?: WsTag[]; +}; + +export type TSecretApprovalSecChange = { + _id: string; + version: number; + secretKey: string; + secretValue: string; + secretComment: string; + tags?: string[]; +}; + +export type TSecretApprovalRequest< + T extends unknown = TSecretApprovalSecChangeData, + J extends unknown = EncryptedSecret +> = { + _id: string; + committer: string; + reviewers: { + member: string; + status: ApprovalStatus; + }[]; + workspace: string; + environment: string; + folderId: string; + hasMerged: boolean; + status: "open" | "close"; + policy: TSecretApprovalPolicy; + commits: { + // if there is no secret means it was creation + secret?: J; + // if there is no new version its for Delete + newVersion?: T; + op: CommitType; + }[]; +}; + +export type TGetSecretApprovalRequestList = { + workspaceId: string; + environment?: string; +}; + +export type TGetSecretApprovalRequestDetails = { + id: string; + decryptKey: UserWsKeyPair; +}; + +export type TUpdateSecretApprovalRequestStatusDTO = { + status: ApprovalStatus; + id: string; +}; diff --git a/frontend/src/hooks/api/secrets/queries.tsx b/frontend/src/hooks/api/secrets/queries.tsx index a9cbc3b29..db000f5c8 100644 --- a/frontend/src/hooks/api/secrets/queries.tsx +++ b/frontend/src/hooks/api/secrets/queries.tsx @@ -26,7 +26,10 @@ export const secretKeys = { getSecretVersion: (secretId: string) => [{ secretId }, "secret-versions"] as const }; -const decryptSecrets = (encryptedSecrets: EncryptedSecret[], decryptFileKey: UserWsKeyPair) => { +export const decryptSecrets = ( + encryptedSecrets: EncryptedSecret[], + decryptFileKey: UserWsKeyPair +) => { const PRIVATE_KEY = localStorage.getItem("PRIVATE_KEY") as string; const key = decryptAssymmetric({ ciphertext: decryptFileKey.encryptedKey, diff --git a/frontend/src/hooks/api/types.ts b/frontend/src/hooks/api/types.ts index b46b5bfab..f6131e5b2 100644 --- a/frontend/src/hooks/api/types.ts +++ b/frontend/src/hooks/api/types.ts @@ -5,13 +5,19 @@ export type { TCloudIntegration, TIntegration } from "./integrations/types"; export type { UserWsKeyPair } from "./keys/types"; export type { Organization } from "./organization/types"; export type { TSecretApprovalPolicy } from "./secretApproval/types"; +export type { + TGetSecretApprovalRequestDetails, + TSecretApprovalRequest, + TSecretApprovalSecChange +} from "./secretApprovalRequest/types"; +export { ApprovalStatus, CommitType } from "./secretApprovalRequest/types"; export type { TSecretFolder } from "./secretFolders/types"; export type { TImportedSecrets, TSecretImports } from "./secretImports/types"; export * from "./secrets/types"; export type { CreateServiceTokenDTO, ServiceToken } from "./serviceTokens/types"; export type { SubscriptionPlan } from "./subscriptions/types"; export type { WsTag } from "./tags/types"; -export type { AddUserToWsDTO, AddUserToWsRes, OrgUser, User } from "./users/types"; +export type { AddUserToWsDTO, AddUserToWsRes, OrgUser, TWorkspaceUser, User } from "./users/types"; export type { TWebhook } from "./webhooks/types"; export type { CreateEnvironmentDTO, diff --git a/frontend/src/views/SecretApprovalPage/SecretApprovalPage.tsx b/frontend/src/views/SecretApprovalPage/SecretApprovalPage.tsx index 35243e75f..f5c8fef8a 100644 --- a/frontend/src/views/SecretApprovalPage/SecretApprovalPage.tsx +++ b/frontend/src/views/SecretApprovalPage/SecretApprovalPage.tsx @@ -2,6 +2,7 @@ import { Tab, TabList, TabPanel, Tabs } from "@app/components/v2"; import { useWorkspace } from "@app/context"; import { SecretApprovalPolicyList } from "./components/SecretApprovalPolicyList"; +import { SecretApprovalRequest } from "./components/SecretApprovalRequest"; enum TabSection { ApprovalRequests = "approval-requests", @@ -22,6 +23,9 @@ export const SecretApprovalPage = () => { Secret PRs Policies + + + diff --git a/frontend/src/views/SecretApprovalPage/components/SecretApprovalPolicyList/SecretApprovalPolicyList.tsx b/frontend/src/views/SecretApprovalPage/components/SecretApprovalPolicyList/SecretApprovalPolicyList.tsx index 700b1a785..333541c01 100644 --- a/frontend/src/views/SecretApprovalPage/components/SecretApprovalPolicyList/SecretApprovalPolicyList.tsx +++ b/frontend/src/views/SecretApprovalPage/components/SecretApprovalPolicyList/SecretApprovalPolicyList.tsx @@ -99,9 +99,11 @@ export const SecretApprovalPolicyList = ({ workspaceId }: Props) => { )} {!isPoliciesLoading && !policies?.length && ( - - - + + + + + )} {policies?.map((policy) => ( { + const { currentWorkspace } = useWorkspace(); + const workspaceId = currentWorkspace?._id || ""; + const [selectedApproval, setSelectedApproval] = useState(null); + + const { data: secretApprovalRequests } = useGetSecretApprovalRequests({ workspaceId }); + const { data: members } = useGetWorkspaceUsers(workspaceId); + const membersGroupById = members?.reduce>( + (prev, curr) => ({ ...prev, [curr._id]: curr }), + {} + ); + + const isSecretApprovalScreen = Boolean(selectedApproval); + + return ( + + {isSecretApprovalScreen ? ( + + setSelectedApproval(null)} + committer={membersGroupById?.[selectedApproval?.committer || ""]} + /> + + ) : ( + +
+
+ + 27 Open +
+
+ + 27 Closed +
+
+
+ {secretApprovalRequests?.map((secretApproval) => { + const { _id: reqId, commits, committer } = secretApproval; + return ( +
setSelectedApproval(secretApproval)} + onKeyDown={(evt) => { + if (evt.key === "Enter") setSelectedApproval(secretApproval); + }} + > +
+ + {generateCommitText(commits)} +
+ + Opened 2 hours ago by {membersGroupById?.[committer]?.user?.firstName}{" "} + {membersGroupById?.[committer]?.user?.lastName} ( + {membersGroupById?.[committer]?.user?.email}) - Review required + +
+ ); + })} +
+
+ )} +
+ ); +}; diff --git a/frontend/src/views/SecretApprovalPage/components/SecretApprovalRequest/components/SecretApprovalRequestChangeItem.tsx b/frontend/src/views/SecretApprovalPage/components/SecretApprovalRequest/components/SecretApprovalRequestChangeItem.tsx new file mode 100644 index 000000000..0fdfbfc94 --- /dev/null +++ b/frontend/src/views/SecretApprovalPage/components/SecretApprovalRequest/components/SecretApprovalRequestChangeItem.tsx @@ -0,0 +1 @@ +type Props = {}; diff --git a/frontend/src/views/SecretApprovalPage/components/SecretApprovalRequest/components/SecretApprovalRequestChanges.tsx b/frontend/src/views/SecretApprovalPage/components/SecretApprovalRequest/components/SecretApprovalRequestChanges.tsx new file mode 100644 index 000000000..b56aec96b --- /dev/null +++ b/frontend/src/views/SecretApprovalPage/components/SecretApprovalRequest/components/SecretApprovalRequestChanges.tsx @@ -0,0 +1,356 @@ +import { ReactNode } from "react"; +import { + faArrowLeft, + faCheck, + faCheckCircle, + faCircle, + faClose, + faCodeBranch, + faXmarkCircle +} from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { + Button, + ContentLoader, + IconButton, + SecretInput, + Table, + TableContainer, + Tag, + TBody, + Td, + Th, + THead, + Tooltip, + Tr +} from "@app/components/v2"; +import { + useGetSecretApprovalRequestDetails, + useGetUserWsKey, + useUpdateSecretApprovalRequestStatus +} from "@app/hooks/api"; +import { ApprovalStatus, CommitType, TWorkspaceUser } from "@app/hooks/api/types"; + +import { useNotificationContext } from "~/components/context/Notifications/NotificationProvider"; + +export const generateCommitText = (commits: { op: CommitType }[] = []) => { + const score: Record = {}; + commits.forEach(({ op }) => { + score[op] = (score?.[op] || 0) + 1; + }); + const text: ReactNode[] = []; + if (score[CommitType.CREATE]) + text.push( + + {score[CommitType.CREATE]} secret{score[CommitType.CREATE] !== 1 && "s"} + created + + ); + if (score[CommitType.UPDATE]) + text.push( + + {Boolean(text.length) && ","} + {score[CommitType.UPDATE]} secret{score[CommitType.UPDATE] !== 1 && "s"} + + {" "} + updated + + + ); + if (score[CommitType.DELETE]) + text.push( + + {Boolean(text.length) && "and"} + {score[CommitType.DELETE]} secret{score[CommitType.UPDATE] !== 1 && "s"} + deleted + + ); + + return text; +}; + +const getReviewedStatusSymbol = (status?: ApprovalStatus) => { + if (status === ApprovalStatus.APPROVED) + return ; + if (status === ApprovalStatus.REJECTED) + return ; + return ; +}; + +type Props = { + workspaceId: string; + approvalRequestId: string; + onGoBack: () => void; + committer?: TWorkspaceUser; + members?: Record; +}; + +export const SecretApprovalRequestChanges = ({ + approvalRequestId, + onGoBack, + committer, + workspaceId, + members = {} +}: Props) => { + const { createNotification } = useNotificationContext(); + const { data: decryptFileKey } = useGetUserWsKey(workspaceId); + const { + data: secretApprovalRequestDetails, + isSuccess: isSecretApprovalRequestSuccess, + isLoading: isSecretApprovalRequestLoading + } = useGetSecretApprovalRequestDetails({ + id: approvalRequestId, + decryptKey: decryptFileKey! + }); + + const { + mutateAsync: updateSecretApprovalRequestStatus, + isLoading: isUpdatingRequestStatus, + variables + } = useUpdateSecretApprovalRequestStatus(); + const isApproving = variables?.status === ApprovalStatus.APPROVED && isUpdatingRequestStatus; + const isRejecting = variables?.status === ApprovalStatus.REJECTED && isUpdatingRequestStatus; + + const reviewedMembers = secretApprovalRequestDetails?.reviewers?.reduce< + Record + >( + (prev, curr) => ({ + ...prev, + [curr.member]: curr.status + }), + {} + ); + + const handleSecretApprovalStatusUpdate = async (status: ApprovalStatus) => { + try { + await updateSecretApprovalRequestStatus({ + id: approvalRequestId, + status + }); + } catch (err) { + console.log(err); + createNotification({ + type: "error", + text: "Failed to update the request status" + }); + } + }; + + if (isSecretApprovalRequestLoading) { +
+ +
; + } + + if (!isSecretApprovalRequestSuccess) return
Failed
; + + return ( +
+
+
+ + + +
+ + {secretApprovalRequestDetails.status} +
+
+
+ {generateCommitText(secretApprovalRequestDetails.commits)} +
+
+ {committer?.user?.firstName} + {committer?.user?.lastName} ({committer?.user?.email}) wants to change{" "} + {secretApprovalRequestDetails.commits.length} secret values in{" "} + + {secretApprovalRequestDetails.environment} + +
+
+ + +
+
+ {secretApprovalRequestDetails.commits.map(({ op, secret, newVersion }, index) => ( +
+ + + + + {op === CommitType.UPDATE && + + + + + + {op === CommitType.UPDATE ? ( + + + + + + + + + + + + + + + + + ) : ( + + + + + + + + + )} +
} + SecretValueCommentTags
OLD{secret?.key} + + {secret?.comment} + {secret?.tags?.map(({ name, _id: tagId, tagColor }) => ( + +
+
{name}
+ + ))} +
NEW{newVersion?.secretKey} + + {newVersion?.secretComment} + {newVersion?.tags?.map(({ name, _id: tagId, tagColor }) => ( + +
+
{name}
+ + ))} +
{op === CommitType.CREATE ? newVersion?.secretKey : secret?.key} + + + {op === CommitType.CREATE ? newVersion?.secretComment : secret?.comment} + + {(op === CommitType.CREATE ? newVersion?.tags : secret?.tags)?.map( + ({ name, _id: tagId, tagColor }) => ( + +
+
{name}
+ + ) + )} +
+
+
+ ))} +
+
+ + +
+
+
+
Reviewers
+
+ {secretApprovalRequestDetails?.policy?.approvers.map((requiredApproverId) => { + const userDetails = members?.[requiredApproverId]?.user; + const status = reviewedMembers?.[requiredApproverId]; + return ( +
+
+ + {userDetails?.email} + + * +
+
+ + {getReviewedStatusSymbol(status)} + +
+
+ ); + })} + {secretApprovalRequestDetails?.reviewers + .filter( + ({ member }) => !secretApprovalRequestDetails?.policy?.approvers?.includes(member) + ) + .map((reviewer) => { + const userDetails = members?.[reviewer.member]?.user; + const status = reviewedMembers?.[reviewer.status]; + return ( +
+
+ + {userDetails?.email} + + * +
+
+ + {getReviewedStatusSymbol(status)} + +
+
+ ); + })} +
+
+
+ ); +}; diff --git a/frontend/src/views/SecretApprovalPage/components/SecretApprovalRequest/index.tsx b/frontend/src/views/SecretApprovalPage/components/SecretApprovalRequest/index.tsx new file mode 100644 index 000000000..5752309ca --- /dev/null +++ b/frontend/src/views/SecretApprovalPage/components/SecretApprovalRequest/index.tsx @@ -0,0 +1 @@ +export { SecretApprovalRequest } from "./SecretApprovalRequest";