From fc43511f5d250e490e392db83ebd8ab7f0853ce0 Mon Sep 17 00:00:00 2001 From: Akhil Mohan Date: Sat, 30 Sep 2023 23:45:22 +0530 Subject: [PATCH 01/12] 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"; From 63588b4e44330e3ebd2cc77a82a203c1115284d1 Mon Sep 17 00:00:00 2001 From: Akhil Mohan Date: Mon, 2 Oct 2023 23:15:03 +0530 Subject: [PATCH 02/12] feat(secret-approval): implemented the new policy based approval system bare version --- .../v1/secretApprovalPolicyController.ts | 18 +- .../v1/secretApprovalRequestsController.ts | 47 +- .../src/controllers/v3/secretsController.ts | 13 +- backend/src/models/secretApprovalRequest.ts | 48 +- backend/src/routes/v1/secretApprovalPolicy.ts | 8 + .../src/routes/v1/secretApprovalRequest.ts | 8 + backend/src/services/SecretApprovalService.ts | 466 ++++++++++++++++-- backend/src/validation/secretApproval.ts | 10 +- .../src/validation/secretApprovalRequest.ts | 8 +- .../src/components/navigation/NavHeader.tsx | 7 +- .../src/hooks/api/secretApproval/index.tsx | 2 +- .../src/hooks/api/secretApproval/queries.tsx | 46 +- .../src/hooks/api/secretApproval/types.ts | 10 + .../hooks/api/secretApprovalRequest/index.tsx | 5 +- .../api/secretApprovalRequest/mutation.tsx | 16 +- .../api/secretApprovalRequest/queries.tsx | 69 ++- .../hooks/api/secretApprovalRequest/types.ts | 11 +- .../SecretApprovalRequest.tsx | 178 +++++-- .../SecretApprovalRequestAction.tsx | 47 ++ .../SecretApprovalRequestChangeItem.tsx | 1 - .../SecretApprovalRequestChanges.tsx | 98 +++- .../views/SecretMainPage/SecretMainPage.tsx | 12 + .../CreateSecretForm/CreateSecretForm.tsx | 8 +- .../SecretDropzone/SecretDropzone.tsx | 8 +- .../SecretListView/SecretDetaiSidebar.tsx | 5 +- .../components/SecretListView/SecretItem.tsx | 6 +- .../SecretListView/SecretListView.tsx | 43 +- 27 files changed, 1033 insertions(+), 165 deletions(-) create mode 100644 frontend/src/views/SecretApprovalPage/components/SecretApprovalRequest/components/SecretApprovalRequestAction.tsx delete mode 100644 frontend/src/views/SecretApprovalPage/components/SecretApprovalRequest/components/SecretApprovalRequestChangeItem.tsx diff --git a/backend/src/controllers/v1/secretApprovalPolicyController.ts b/backend/src/controllers/v1/secretApprovalPolicyController.ts index 245932353..c9afe2af4 100644 --- a/backend/src/controllers/v1/secretApprovalPolicyController.ts +++ b/backend/src/controllers/v1/secretApprovalPolicyController.ts @@ -1,4 +1,4 @@ -import { ForbiddenError } from "@casl/ability"; +import { ForbiddenError, subject } from "@casl/ability"; import { Request, Response } from "express"; import { ProjectPermissionActions, @@ -7,6 +7,7 @@ import { } from "../../ee/services/ProjectRoleService"; import { validateRequest } from "../../helpers/validation"; import { SecretApprovalPolicy } from "../../models/secretApprovalPolicy"; +import { getSecretPolicyOfBoard } from "../../services/SecretApprovalService"; import { BadRequestError } from "../../utils/errors"; import * as reqValidator from "../../validation/secretApproval"; @@ -107,3 +108,18 @@ export const getSecretApprovalPolicy = async (req: Request, res: Response) => { approvals: doc }); }; + +export const getSecretApprovalPolicyOfBoard = async (req: Request, res: Response) => { + const { + query: { workspaceId, environment, secretPath } + } = await validateRequest(reqValidator.GetSecretApprovalPolicyOfABoard, req); + + const { permission } = await getUserProjectPermissions(req.user._id, workspaceId); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Read, + subject(ProjectPermissionSub.Secrets, { secretPath, environment }) + ); + + const secretApprovalPolicy = await getSecretPolicyOfBoard(workspaceId, environment, secretPath); + return res.send({ policy: secretApprovalPolicy }); +}; diff --git a/backend/src/controllers/v1/secretApprovalRequestsController.ts b/backend/src/controllers/v1/secretApprovalRequestsController.ts index 8672ba5f6..c35d65866 100644 --- a/backend/src/controllers/v1/secretApprovalRequestsController.ts +++ b/backend/src/controllers/v1/secretApprovalRequestsController.ts @@ -2,11 +2,12 @@ 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 { ApprovalStatus, 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"; +import { performSecretApprovalRequestMerge } from "../../services/SecretApprovalService"; export const getSecretApprovalRequests = async (req: Request, res: Response) => { const { @@ -29,6 +30,7 @@ export const getSecretApprovalRequests = async (req: Request, res: Response) => ([key, value]) => value === undefined && delete query[key as keyof typeof query] ); const approvalRequests = await SecretApprovalRequest.find(query) + .sort({ createdAt: -1 }) .limit(limit) .skip(offset) .populate("policy") @@ -64,11 +66,12 @@ export const getSecretApprovalRequestDetails = async (req: Request, res: Respons const secretApprovalRequest = await SecretApprovalRequest.findById(id) .populate("policy") .populate({ - path: "commits.secret", + path: "commits.secretVersion", populate: { path: "tags" } }) + .populate("commits.secret", "version") .populate("commits.newVersion.tags"); if (!secretApprovalRequest) throw BadRequestError({ message: "Secret approval request not found" }); @@ -126,3 +129,43 @@ export const updateSecretApprovalRequestStatus = async (req: Request, res: Respo return res.send({ status }); }; + +export const mergeSecretApprovalRequest = async (req: Request, res: Response) => { + const { + body: { id } + } = await validateRequest(reqValidator.mergeSecretApprovalRequest, 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 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 BadRequestError({ message: "Doesn't have minimum approvals needed" }); + + const approval = await performSecretApprovalRequestMerge(id, req.authData); + return res.send({ approval }); +}; diff --git a/backend/src/controllers/v3/secretsController.ts b/backend/src/controllers/v3/secretsController.ts index 84a324afa..dd3e19200 100644 --- a/backend/src/controllers/v3/secretsController.ts +++ b/backend/src/controllers/v3/secretsController.ts @@ -596,7 +596,7 @@ export const createSecret = async (req: Request, res: Response) => { }); const secretApprovalPolicy = await getSecretPolicyOfBoard(workspaceId, environment, secretPath); - if (secretApprovalPolicy && membership) { + if (secretApprovalPolicy && membership && type !== "personal") { const secretApprovalRequest = await generateSecretApprovalRequest({ workspaceId, environment, @@ -701,7 +701,7 @@ export const updateSecretByName = async (req: Request, res: Response) => { }); const secretApprovalPolicy = await getSecretPolicyOfBoard(workspaceId, environment, secretPath); - if (secretApprovalPolicy && membership) { + if (secretApprovalPolicy && membership && type !== "personal") { const secretApprovalRequest = await generateSecretApprovalRequest({ workspaceId, environment, @@ -712,6 +712,7 @@ export const updateSecretByName = async (req: Request, res: Response) => { [CommitType.UPDATE]: [ { secretName, + newSecretName, secretValueCiphertext, secretValueIV, secretValueTag, @@ -784,7 +785,7 @@ export const deleteSecretByName = async (req: Request, res: Response) => { }); const secretApprovalPolicy = await getSecretPolicyOfBoard(workspaceId, environment, secretPath); - if (secretApprovalPolicy && membership) { + if (secretApprovalPolicy && membership && type !== "personal") { const secretApprovalRequest = await generateSecretApprovalRequest({ workspaceId, environment, @@ -846,7 +847,7 @@ export const createSecretByNameBatch = async (req: Request, res: Response) => { policy: secretApprovalPolicy, commiterMembershipId: membership._id.toString(), data: { - [CommitType.CREATE]: secrets + [CommitType.CREATE]: secrets.filter(({ type }) => type === "shared") } }); return res.send({ approval: secretApprovalRequest }); @@ -887,7 +888,7 @@ export const updateSecretByNameBatch = async (req: Request, res: Response) => { policy: secretApprovalPolicy, commiterMembershipId: membership._id.toString(), data: { - [CommitType.UPDATE]: secrets + [CommitType.UPDATE]: secrets.filter(({ type }) => type === "shared") } }); return res.send({ approval: secretApprovalRequest }); @@ -928,7 +929,7 @@ export const deleteSecretByNameBatch = async (req: Request, res: Response) => { policy: secretApprovalPolicy, commiterMembershipId: membership._id.toString(), data: { - [CommitType.DELETE]: secrets + [CommitType.DELETE]: secrets.filter(({ type }) => type === "shared") } }); return res.send({ approval: secretApprovalRequest }); diff --git a/backend/src/models/secretApprovalRequest.ts b/backend/src/models/secretApprovalRequest.ts index 58ba38a7e..9459e8189 100644 --- a/backend/src/models/secretApprovalRequest.ts +++ b/backend/src/models/secretApprovalRequest.ts @@ -32,6 +32,25 @@ export interface ISecretApprovalSecChange { tags?: string[]; } +export type ISecretCommits = Array< + | { + newVersion: ISecretApprovalSecChange; + op: CommitType.CREATE; + } + | { + // secret is recorded to get the latest version, we can keep ref to secret for pulling change as it will also get changed + // on merge + secretVersion: J; + secret: T; + newVersion: Partial> & { _id: Types.ObjectId }; + op: CommitType.UPDATE; + } + | { + secret: T; + secretVersion: J; + op: CommitType.DELETE; + } +>; export interface ISecretApprovalRequest { _id: Types.ObjectId; committer: Types.ObjectId; @@ -45,21 +64,8 @@ export interface ISecretApprovalRequest { hasMerged: boolean; 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; - } - >; + commits: ISecretCommits; + conflicts: Array<{ secretId: string; op: CommitType }>; } const secretApprovalSecretChangeSchema = new Schema({ @@ -157,9 +163,19 @@ const secretApprovalRequestSchema = new Schema( { secret: { type: Types.ObjectId, ref: "Secret" }, newVersion: secretApprovalSecretChangeSchema, + secretVersion: { type: Types.ObjectId, ref: "SecretVersion" }, op: { type: String, enum: [CommitType], required: true } } - ] + ], + conflicts: { + type: [ + { + secretId: { type: String, required: true }, + op: { type: String, enum: [CommitType], required: true } + } + ], + default: [] + } }, { timestamps: true diff --git a/backend/src/routes/v1/secretApprovalPolicy.ts b/backend/src/routes/v1/secretApprovalPolicy.ts index 51c2aa3e7..2147c20a2 100644 --- a/backend/src/routes/v1/secretApprovalPolicy.ts +++ b/backend/src/routes/v1/secretApprovalPolicy.ts @@ -12,6 +12,14 @@ router.get( secretApprovalPolicyController.getSecretApprovalPolicy ); +router.get( + "/board", + requireAuth({ + acceptedAuthModes: [AuthMode.JWT] + }), + secretApprovalPolicyController.getSecretApprovalPolicyOfBoard +); + router.post( "/", requireAuth({ diff --git a/backend/src/routes/v1/secretApprovalRequest.ts b/backend/src/routes/v1/secretApprovalRequest.ts index 7e3ed02e1..b7737411b 100644 --- a/backend/src/routes/v1/secretApprovalRequest.ts +++ b/backend/src/routes/v1/secretApprovalRequest.ts @@ -20,6 +20,14 @@ router.get( secretApprovalRequestController.getSecretApprovalRequestDetails ); +router.post( + "/merge", + requireAuth({ + acceptedAuthModes: [AuthMode.JWT] + }), + secretApprovalRequestController.mergeSecretApprovalRequest +); + router.post( "/:id", requireAuth({ diff --git a/backend/src/services/SecretApprovalService.ts b/backend/src/services/SecretApprovalService.ts index 2c27ff777..3f02d57a4 100644 --- a/backend/src/services/SecretApprovalService.ts +++ b/backend/src/services/SecretApprovalService.ts @@ -11,11 +11,16 @@ import { CommitType, ISecretApprovalRequest, ISecretApprovalSecChange, + ISecretCommits, SecretApprovalRequest } from "../models/secretApprovalRequest"; import { BadRequestError } from "../utils/errors"; import { getFolderByPath } from "./FolderService"; -import { SECRET_SHARED } from "../variables"; +import { ALGORITHM_AES_256_GCM, ENCODING_SCHEME_UTF8, SECRET_SHARED } from "../variables"; +import TelemetryService from "./TelemetryService"; +import { EEAuditLogService, EESecretService } from "../ee/services"; +import { EventType, SecretVersion } from "../ee/models"; +import { AuthData } from "../interfaces/middleware"; // 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) => @@ -43,6 +48,35 @@ export const getSecretPolicyOfBoard = async ( return finalPolicy; }; +const getLatestSecretVersion = async (secretIds: Types.ObjectId[]) => { + const latestSecretVersions = await SecretVersion.aggregate([ + { + $match: { + secret: { + $in: secretIds + }, + type: SECRET_SHARED + } + }, + { + $sort: { version: -1 } + }, + { + $group: { + _id: "$secret", + version: { $max: "$version" }, + versionId: { $max: "$_id" }, // id of latest secret versionId + secret: { $first: "$$ROOT" } + } + } + ]).exec(); + // reduced with secret id and latest version as document + return latestSecretVersions.reduce( + (prev, curr) => ({ ...prev, [curr._id.toString()]: curr.secret }), + {} + ); +}; + type TApprovalCreateSecret = Omit & { secretName: string; }; @@ -50,6 +84,7 @@ type TApprovalUpdateSecret = Partial secretBlindIndexes[secretName]) + } }) .select("+secretBlindIndex") - .or( - updatedSecret.map(({ secretName }) => ({ - secretBlindIndex: secretBlindIndexes[secretName], - type: SECRET_SHARED - })) - ) .lean() .exec(); - if (secretsToBeUpdated.length !== updatedSecret.length) + if (oldSecrets.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( @@ -188,24 +222,29 @@ export const generateSecretApprovalRequest = async ({ environment, secretBlindIndex: { $in: Object.values(newSecretBlindIndexes) } }); - if (doesAnySecretExistWithNewIndex) + if (doesAnySecretExistWithNewIndex.length) throw BadRequestError({ message: "Secret with new name already exist" }); + const oldSecretsGroupById = oldSecrets.reduce>( + (prev, curr) => ({ ...prev, [curr?.secretBlindIndex || ""]: curr }), + {} + ); + const latestSecretVersions = await getLatestSecretVersion( + updatedSecret.map((el) => oldSecretsGroupById[secretBlindIndexes[el.secretName]]._id) + ); + commits.push( ...updatedSecret.map((el) => { - const oldSecret = secretsToBeUpdated.find( - (sec) => sec?.secretBlindIndex === secretBlindIndexes[el.secretName] - ); - if (!oldSecret) throw BadRequestError({ message: "Secret not found" }); - + const secretId = oldSecretsGroupById[secretBlindIndexes[el.secretName]]._id; return { op: CommitType.UPDATE as const, - secret: oldSecret._id, + secret: secretId, + secretVersion: latestSecretVersions[secretId.toString()]._id, newVersion: { ...el, secretBlindIndex: newSecretBlindIndexes?.[el.secretName], _id: new Types.ObjectId(), - version: oldSecret.version || 1 + version: oldSecretsGroupById[secretBlindIndexes[el.secretName]].version || 1 } }; }) @@ -233,29 +272,35 @@ export const generateSecretApprovalRequest = async ({ const secretsToDelete = await Secret.find({ workspace: new Types.ObjectId(workspaceId), folder: folderId, - environment + environment, + type: SECRET_SHARED, + secretBlindIndex: { + $in: deletedSecrets.map(({ secretName }) => secretBlindIndexes[secretName]) + } }) - .or( - deletedSecrets.map(({ secretName }) => ({ - secretBlindIndex: secretBlindIndexes[secretName], - type: SECRET_SHARED - })) - ) - .select({ secretBlindIndexes: 1 }) + .select({ secretBlindIndex: 1, _id: 1 }) .lean() .exec(); if (secretsToDelete.length !== deletedSecrets.length) throw BadRequestError({ message: "Deleted secrets not found" }); + const oldSecretsGroupById = secretsToDelete.reduce>( + (prev, curr) => ({ ...prev, [curr?.secretBlindIndex || ""]: curr }), + {} + ); + const latestSecretVersions = await getLatestSecretVersion( + deletedSecrets.map((el) => oldSecretsGroupById[secretBlindIndexes[el.secretName]]._id) + ); + commits.push( - ...deletedSecrets.map((el) => ({ - op: CommitType.DELETE as const, - secret: ( - secretsToDelete.find( - (sec) => sec?.secretBlindIndex === secretBlindIndexes[el.secretName] - ) as ISecret - )._id - })) + ...deletedSecrets.map((el) => { + const secretId = oldSecretsGroupById[secretBlindIndexes[el.secretName]]._id; + return { + op: CommitType.DELETE as const, + secret: secretId, + secretVersion: latestSecretVersions[secretId.toString()] + }; + }) ); } @@ -270,3 +315,356 @@ export const generateSecretApprovalRequest = async ({ await secretApprovalRequest.save(); return secretApprovalRequest; }; + +// validation for a merge conditions happen in another function in controller +export const performSecretApprovalRequestMerge = async (id: string, authData: AuthData) => { + const secretApprovalRequest = await SecretApprovalRequest.findById(id) + .populate<{ commits: ISecretCommits }>({ + path: "commits.secret", + select: "+secretBlindIndex", + populate: { + path: "tags" + } + }) + .select("+commits.newVersion.secretBlindIndex"); + if (!secretApprovalRequest) throw BadRequestError({ message: "Approval request not found" }); + + const workspaceId = secretApprovalRequest.workspace; + const environment = secretApprovalRequest.environment; + const folderId = secretApprovalRequest.folderId; + const postHogClient = await TelemetryService.getPostHogClient(); + const conflicts: Array<{ id: string; op: CommitType }> = []; + + const secretCreationCommits = secretApprovalRequest.commits.filter( + ({ op }) => op === CommitType.CREATE + ) as Array<{ op: CommitType.CREATE; newVersion: ISecretApprovalSecChange }>; + if (secretCreationCommits.length) { + // the created secrets already exist thus creation conflict ones + const conflictedSecrets = await Secret.find({ + workspace: workspaceId, + environment, + folder: folderId, + secretBlindIndex: { + $in: secretCreationCommits.map(({ newVersion }) => newVersion.secretBlindIndex) + } + }) + .select("+secretBlindIndex") + .lean(); + const conflictGroupByBlindIndex = conflictedSecrets.reduce>( + (prev, curr) => ({ ...prev, [curr.secretBlindIndex || ""]: true }), + {} + ); + const nonConflictSecrets = secretCreationCommits.filter( + ({ newVersion }) => !conflictGroupByBlindIndex[newVersion.secretBlindIndex || ""] + ); + secretCreationCommits + .filter(({ newVersion }) => conflictGroupByBlindIndex[newVersion.secretBlindIndex || ""]) + .forEach((el) => { + conflicts.push({ op: CommitType.CREATE, id: el.newVersion._id.toString() }); + }); + + // create secret + const newlyCreatedSecrets: ISecret[] = await Secret.insertMany( + nonConflictSecrets.map( + ({ + newVersion: { + secretKeyIV, + secretKeyTag, + secretValueIV, + secretValueTag, + secretCommentIV, + secretCommentTag, + secretKeyCiphertext, + secretValueCiphertext, + secretCommentCiphertext, + skipMultilineEncoding, + secretBlindIndex, + algorithm, + keyEncoding, + tags + } + }) => ({ + version: 1, + workspace: new Types.ObjectId(workspaceId), + environment, + type: SECRET_SHARED, + secretKeyCiphertext, + secretKeyIV, + secretKeyTag, + secretValueCiphertext, + secretValueIV, + secretValueTag, + secretCommentCiphertext, + secretCommentIV, + secretCommentTag, + folder: folderId, + algorithm: algorithm || ALGORITHM_AES_256_GCM, + keyEncoding: keyEncoding || ENCODING_SCHEME_UTF8, + tags, + skipMultilineEncoding, + secretBlindIndex + }) + ) + ); + + await EESecretService.addSecretVersions({ + secretVersions: newlyCreatedSecrets.map( + (secret) => + new SecretVersion({ + secret: secret._id, + version: secret.version, + workspace: secret.workspace, + type: secret.type, + folder: folderId, + tags: secret.tags, + skipMultilineEncoding: secret?.skipMultilineEncoding, + environment: secret.environment, + isDeleted: false, + secretBlindIndex: secret.secretBlindIndex, + secretKeyCiphertext: secret.secretKeyCiphertext, + secretKeyIV: secret.secretKeyIV, + secretKeyTag: secret.secretKeyTag, + secretValueCiphertext: secret.secretValueCiphertext, + secretValueIV: secret.secretValueIV, + secretValueTag: secret.secretValueTag, + algorithm: ALGORITHM_AES_256_GCM, + keyEncoding: ENCODING_SCHEME_UTF8 + }) + ) + }); + + // question to team where to keep secretKey + await EEAuditLogService.createAuditLog( + authData, + { + type: EventType.CREATE_SECRETS, + metadata: { + environment, + secretPath: "/", + secrets: newlyCreatedSecrets.map(({ version, _id }) => ({ + secretId: _id.toString(), + secretKey: "", + secretVersion: version + })) + } + }, + { + workspaceId + } + ); + } + + const secretUpdationCommits = secretApprovalRequest.commits.filter( + ({ op }) => op === CommitType.UPDATE + ) as Array<{ + op: CommitType.UPDATE; + newVersion: Partial> & { _id: Types.ObjectId }; + secret: ISecret; + }>; + if (secretUpdationCommits.length) { + const conflictedByNewBlindIndex = await Secret.find({ + workspace: workspaceId, + environment, + folder: folderId, + secretBlindIndex: { + $in: secretUpdationCommits + .map(({ newVersion }) => newVersion?.secretBlindIndex) + .filter(Boolean) + } + }) + .select("+secretBlindIndex") + .lean(); + const conflictGroupByBlindIndex = conflictedByNewBlindIndex.reduce>( + (prev, curr) => (curr?.secretBlindIndex ? { ...prev, [curr.secretBlindIndex]: true } : prev), + {} + ); + secretUpdationCommits + .filter( + ({ newVersion }) => + newVersion.secretBlindIndex && conflictGroupByBlindIndex[newVersion.secretBlindIndex] + ) + .forEach((el) => { + conflicts.push({ op: CommitType.UPDATE, id: el.newVersion._id.toString() }); + }); + + const nonConflictSecrets = secretUpdationCommits.filter(({ newVersion }) => + newVersion?.secretBlindIndex ? !conflictGroupByBlindIndex[newVersion.secretBlindIndex] : true + ); + await Secret.bulkWrite( + // id and version are stripped off + nonConflictSecrets.map( + ({ + newVersion: { + secretKeyIV, + secretKeyTag, + secretValueIV, + secretValueTag, + secretCommentIV, + secretCommentTag, + secretKeyCiphertext, + secretValueCiphertext, + secretCommentCiphertext, + skipMultilineEncoding, + secretBlindIndex, + tags + }, + secret + }) => ({ + updateOne: { + filter: { + workspace: new Types.ObjectId(workspaceId), + environment, + folder: folderId, + secretBlindIndex: secret.secretBlindIndex, + type: SECRET_SHARED + }, + update: { + $inc: { + version: 1 + }, + secretKeyIV, + secretKeyTag, + secretValueIV, + secretValueTag, + secretCommentIV, + secretCommentTag, + secretKeyCiphertext, + secretValueCiphertext, + secretCommentCiphertext, + skipMultilineEncoding, + secretBlindIndex, + tags, + algorithm: ALGORITHM_AES_256_GCM, + keyEncoding: ENCODING_SCHEME_UTF8 + } + } + }) + ) + ); + + await EESecretService.addSecretVersions({ + secretVersions: nonConflictSecrets.map(({ newVersion, secret }) => { + return new SecretVersion({ + secret: secret._id, + version: secret.version + 1, + workspace: workspaceId, + type: SECRET_SHARED, + folder: folderId, + environment, + isDeleted: false, + secretBlindIndex: newVersion?.secretBlindIndex ?? secret.secretBlindIndex, + secretKeyCiphertext: newVersion?.secretKeyCiphertext ?? secret.secretKeyCiphertext, + secretKeyIV: newVersion?.secretKeyIV ?? secret.secretKeyCiphertext, + secretKeyTag: newVersion?.secretKeyTag ?? secret.secretKeyTag, + secretValueCiphertext: newVersion?.secretValueCiphertext ?? secret.secretValueCiphertext, + secretValueIV: newVersion?.secretValueIV ?? secret.secretValueIV, + secretValueTag: newVersion?.secretValueTag ?? secret.secretValueTag, + tags: newVersion?.tags ?? secret.tags, + algorithm: ALGORITHM_AES_256_GCM, + keyEncoding: ENCODING_SCHEME_UTF8, + skipMultilineEncoding: newVersion?.skipMultilineEncoding ?? secret.skipMultilineEncoding + }); + }) + }); + + await EEAuditLogService.createAuditLog( + authData, + { + type: EventType.UPDATE_SECRETS, + metadata: { + environment, + secretPath: "/", + secrets: nonConflictSecrets.map(({ secret }) => ({ + secretId: secret._id.toString(), + secretKey: "", + secretVersion: secret.version + 1 + })) + } + }, + { + workspaceId + } + ); + } + + const secretDeletionCommits = secretApprovalRequest.commits.filter( + ({ op }) => op === CommitType.DELETE + ) as Array<{ + op: CommitType.DELETE; + secret: ISecret; + }>; + if (secretDeletionCommits.length) { + await Secret.deleteMany({ + workspace: new Types.ObjectId(workspaceId), + folder: folderId, + environment + }) + .or( + secretDeletionCommits.map(({ secret: { secretBlindIndex } }) => ({ + secretBlindIndex, + type: { $in: ["shared", "personal"] } + })) + ) + .exec(); + + await EESecretService.markDeletedSecretVersions({ + secretIds: secretDeletionCommits.map(({ secret }) => secret._id) + }); + + await EEAuditLogService.createAuditLog( + authData, + { + type: EventType.DELETE_SECRETS, + metadata: { + environment, + secretPath: "/", + secrets: secretDeletionCommits.map(({ secret: { _id, version } }) => ({ + secretId: _id.toString(), + secretKey: "", + secretVersion: version + })) + } + }, + { + workspaceId + } + ); + } + + const updatedSecretApproval = await SecretApprovalRequest.findByIdAndUpdate( + id, + { + conflicts, + hasMerged: true, + status: "close" + }, + { new: true } + ); + + if (postHogClient) { + if (postHogClient) { + postHogClient.capture({ + event: "secrets merged", + distinctId: await TelemetryService.getDistinctId({ + authData + }), + properties: { + numberOfSecrets: secretApprovalRequest.commits.length, + environment, + workspaceId, + folderId, + channel: authData.userAgentType, + userAgent: authData.userAgent + } + }); + } + } + + await EESecretService.takeSecretSnapshot({ + workspaceId, + environment, + folderId + }); + + return updatedSecretApproval; +}; diff --git a/backend/src/validation/secretApproval.ts b/backend/src/validation/secretApproval.ts index 9d4b10ce5..1d2b1177d 100644 --- a/backend/src/validation/secretApproval.ts +++ b/backend/src/validation/secretApproval.ts @@ -2,7 +2,15 @@ import { z } from "zod"; export const GetSecretApprovalRuleList = z.object({ query: z.object({ - workspaceId: z.string() + workspaceId: z.string().trim() + }) +}); + +export const GetSecretApprovalPolicyOfABoard = z.object({ + query: z.object({ + workspaceId: z.string().trim(), + environment: z.string().trim(), + secretPath: z.string().trim() }) }); diff --git a/backend/src/validation/secretApprovalRequest.ts b/backend/src/validation/secretApprovalRequest.ts index df339a84a..0f8e66277 100644 --- a/backend/src/validation/secretApprovalRequest.ts +++ b/backend/src/validation/secretApprovalRequest.ts @@ -6,7 +6,7 @@ export const getSecretApprovalRequests = z.object({ workspaceId: z.string().trim(), environment: z.string().trim().optional(), committer: z.string().trim().optional(), - status: z.string().trim().optional(), + status: z.enum(["open", "close"]).optional(), limit: z.coerce.number().default(20), offset: z.coerce.number().default(0) }) @@ -26,3 +26,9 @@ export const updateSecretApprovalRequestStatus = z.object({ id: z.string().trim() }) }); + +export const mergeSecretApprovalRequest = z.object({ + body: z.object({ + id: z.string().trim() + }) +}); diff --git a/frontend/src/components/navigation/NavHeader.tsx b/frontend/src/components/navigation/NavHeader.tsx index 866010c21..2edc1b01e 100644 --- a/frontend/src/components/navigation/NavHeader.tsx +++ b/frontend/src/components/navigation/NavHeader.tsx @@ -1,6 +1,6 @@ import Link from "next/link"; import { useRouter } from "next/router"; -import { faAngleRight } from "@fortawesome/free-solid-svg-icons"; +import { faAngleRight, faShield } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { useOrganization, useWorkspace } from "@app/context"; @@ -16,6 +16,7 @@ type Props = { onEnvChange?: (slug: string) => void; secretPath?: string; isFolderMode?: boolean; + isProtectedBranch?: boolean; }; // TODO: make links clickable and clean up @@ -42,7 +43,8 @@ export default function NavHeader({ userAvailableEnvs = [], onEnvChange, isFolderMode, - secretPath = "/" + secretPath = "/", + isProtectedBranch = false }: Props): JSX.Element { const { currentWorkspace } = useWorkspace(); const { currentOrg } = useOrganization(); @@ -151,6 +153,7 @@ export default function NavHeader({ ); })} + {isProtectedBranch && } ); } diff --git a/frontend/src/hooks/api/secretApproval/index.tsx b/frontend/src/hooks/api/secretApproval/index.tsx index 1d4353d9e..41774b9d1 100644 --- a/frontend/src/hooks/api/secretApproval/index.tsx +++ b/frontend/src/hooks/api/secretApproval/index.tsx @@ -3,4 +3,4 @@ export { useDeleteSecretApprovalPolicy, useUpdateSecretApprovalPolicy } from "./mutation"; -export { useGetSecretApprovalPolicies } from "./queries"; +export { useGetSecretApprovalPolicies, useGetSecretApprovalPolicyOfABoard } from "./queries"; diff --git a/frontend/src/hooks/api/secretApproval/queries.tsx b/frontend/src/hooks/api/secretApproval/queries.tsx index 6c176a27a..4b176e073 100644 --- a/frontend/src/hooks/api/secretApproval/queries.tsx +++ b/frontend/src/hooks/api/secretApproval/queries.tsx @@ -2,11 +2,19 @@ import { useQuery, UseQueryOptions } from "@tanstack/react-query"; import { apiRequest } from "@app/config/request"; -import { TSecretApprovalPolicy } from "./types"; +import { + TGetSecretApprovalPoliciesDTO, + TGetSecretApprovalPolicyOfBoardDTO, + TSecretApprovalPolicy +} from "./types"; export const secretApprovalKeys = { getApprovalPolicies: (workspaceId: string) => - [{ workspaceId }, "secret-approval-policies"] as const + [{ workspaceId }, "secret-approval-policies"] as const, + getApprovalPolicyOfABoard: (workspaceId: string, environment: string, secretPath: string) => [ + { workspaceId, environment, secretPath }, + "Secret-approval-policy" + ] }; const fetchApprovalPolicies = async (workspaceId: string) => { @@ -20,7 +28,7 @@ const fetchApprovalPolicies = async (workspaceId: string) => { export const useGetSecretApprovalPolicies = ({ workspaceId, options = {} -}: { workspaceId: string } & { +}: TGetSecretApprovalPoliciesDTO & { options?: UseQueryOptions< TSecretApprovalPolicy[], unknown, @@ -34,3 +42,35 @@ export const useGetSecretApprovalPolicies = ({ ...options, enabled: Boolean(workspaceId) && (options?.enabled ?? true) }); + +const fetchApprovalPolicyOfABoard = async ( + workspaceId: string, + environment: string, + secretPath: string +) => { + const { data } = await apiRequest.get<{ policy: TSecretApprovalPolicy }>( + "/api/v1/secret-approvals/board", + { params: { workspaceId, environment, secretPath } } + ); + return data.policy; +}; + +export const useGetSecretApprovalPolicyOfABoard = ({ + workspaceId, + secretPath = "/", + environment, + options = {} +}: TGetSecretApprovalPolicyOfBoardDTO & { + options?: UseQueryOptions< + TSecretApprovalPolicy, + unknown, + TSecretApprovalPolicy, + ReturnType + >; +}) => + useQuery({ + queryKey: secretApprovalKeys.getApprovalPolicyOfABoard(workspaceId, environment, secretPath), + queryFn: () => fetchApprovalPolicyOfABoard(workspaceId, environment, secretPath), + ...options, + enabled: Boolean(workspaceId && secretPath && environment) && (options?.enabled ?? true) + }); diff --git a/frontend/src/hooks/api/secretApproval/types.ts b/frontend/src/hooks/api/secretApproval/types.ts index 8eb486664..abf98d706 100644 --- a/frontend/src/hooks/api/secretApproval/types.ts +++ b/frontend/src/hooks/api/secretApproval/types.ts @@ -7,6 +7,16 @@ export type TSecretApprovalPolicy = { approvals: number; }; +export type TGetSecretApprovalPoliciesDTO = { + workspaceId: string; +}; + +export type TGetSecretApprovalPolicyOfBoardDTO = { + workspaceId: string; + environment: string; + secretPath: string; +}; + export type TCreateSecretPolicyDTO = { workspaceId: string; environment: string; diff --git a/frontend/src/hooks/api/secretApprovalRequest/index.tsx b/frontend/src/hooks/api/secretApprovalRequest/index.tsx index cf677c453..26b5168f5 100644 --- a/frontend/src/hooks/api/secretApprovalRequest/index.tsx +++ b/frontend/src/hooks/api/secretApprovalRequest/index.tsx @@ -1,2 +1,5 @@ -export { useUpdateSecretApprovalRequestStatus } from "./mutation"; +export { + usePerformSecretApprovalRequestMerge, + 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 index 877c97922..8ea0335ab 100644 --- a/frontend/src/hooks/api/secretApprovalRequest/mutation.tsx +++ b/frontend/src/hooks/api/secretApprovalRequest/mutation.tsx @@ -3,7 +3,7 @@ import { useMutation, useQueryClient } from "@tanstack/react-query"; import { apiRequest } from "@app/config/request"; import { secretApprovalRequestKeys } from "./queries"; -import { TUpdateSecretApprovalRequestStatusDTO } from "./types"; +import { TPerformSecretApprovalRequestMerge, TUpdateSecretApprovalRequestStatusDTO } from "./types"; export const useUpdateSecretApprovalRequestStatus = () => { const queryClient = useQueryClient(); @@ -18,3 +18,17 @@ export const useUpdateSecretApprovalRequestStatus = () => { } }); }; + +export const usePerformSecretApprovalRequestMerge = () => { + const queryClient = useQueryClient(); + + return useMutation<{}, {}, TPerformSecretApprovalRequestMerge>({ + mutationFn: async ({ id }) => { + const { data } = await apiRequest.post("/api/v1/secret-approval-requests/merge", { id }); + 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 index 63f923071..929a579f8 100644 --- a/frontend/src/hooks/api/secretApprovalRequest/queries.tsx +++ b/frontend/src/hooks/api/secretApprovalRequest/queries.tsx @@ -1,4 +1,9 @@ -import { useQuery, UseQueryOptions } from "@tanstack/react-query"; +import { + useInfiniteQuery, + UseInfiniteQueryOptions, + useQuery, + UseQueryOptions +} from "@tanstack/react-query"; import { decryptAssymmetric, @@ -18,8 +23,18 @@ import { } from "./types"; export const secretApprovalRequestKeys = { - list: ({ workspaceId, environment }: TGetSecretApprovalRequestList) => - [{ workspaceId, environment }, "secret-approval-requests"] as const, + list: ({ + workspaceId, + environment, + status, + committer, + offset, + limit + }: TGetSecretApprovalRequestList) => + [ + { workspaceId, environment, status, committer, offset, limit }, + "secret-approval-requests" + ] as const, detail: ({ id }: Omit) => [{ id }, "secret-approval-request-detail"] as const }; @@ -68,14 +83,22 @@ export const decryptSecretApprovalSecret = ( const fetchSecretApprovalRequestList = async ({ workspaceId, - environment + environment, + committer, + status = "open", + limit = 20, + offset }: TGetSecretApprovalRequestList) => { const { data } = await apiRequest.get<{ approvals: TSecretApprovalRequest[] }>( "/api/v1/secret-approval-requests", { params: { workspaceId, - environment + environment, + committer, + status, + limit, + offset } } ); @@ -86,10 +109,13 @@ const fetchSecretApprovalRequestList = async ({ export const useGetSecretApprovalRequests = ({ workspaceId, environment, - options = {} + options = {}, + status, + limit = 20, + committer }: TGetSecretApprovalRequestList & { options?: Omit< - UseQueryOptions< + UseInfiniteQueryOptions< TSecretApprovalRequest[], unknown, TSecretApprovalRequest[], @@ -98,10 +124,26 @@ export const useGetSecretApprovalRequests = ({ "queryKey" | "queryFn" >; }) => - useQuery({ - queryKey: secretApprovalRequestKeys.list({ workspaceId, environment }), - queryFn: () => fetchSecretApprovalRequestList({ workspaceId, environment }), - enabled: Boolean(workspaceId) && (options?.enabled ?? true) + useInfiniteQuery({ + queryKey: secretApprovalRequestKeys.list({ + workspaceId, + environment, + committer, + status + }), + queryFn: ({ pageParam }) => + fetchSecretApprovalRequestList({ + workspaceId, + environment, + status, + committer, + limit, + offset: pageParam + }), + enabled: Boolean(workspaceId) && (options?.enabled ?? true), + getNextPageParam: (lastPage, pages) => { + return lastPage?.length !== 0 ? pages.length * limit : undefined; + } }); const fetchSecretApprovalRequestDetails = async ({ @@ -134,9 +176,10 @@ export const useGetSecretApprovalRequestDetails = ({ queryFn: () => fetchSecretApprovalRequestDetails({ id }), select: (data) => ({ ...data, - commits: data.commits.map(({ secret, op, newVersion }) => ({ + commits: data.commits.map(({ secretVersion, op, newVersion, secret }) => ({ op, - secret: secret ? decryptSecrets([secret], decryptKey)[0] : undefined, + secret, + secretVersion: secretVersion ? decryptSecrets([secretVersion], decryptKey)[0] : undefined, newVersion: newVersion ? decryptSecretApprovalSecret(newVersion, decryptKey) : undefined })) }), diff --git a/frontend/src/hooks/api/secretApprovalRequest/types.ts b/frontend/src/hooks/api/secretApprovalRequest/types.ts index 377df3e1f..f1ea1ad90 100644 --- a/frontend/src/hooks/api/secretApprovalRequest/types.ts +++ b/frontend/src/hooks/api/secretApprovalRequest/types.ts @@ -60,7 +60,8 @@ export type TSecretApprovalRequest< policy: TSecretApprovalPolicy; commits: { // if there is no secret means it was creation - secret?: J; + secret?: { version: number }; + secretVersion: J; // if there is no new version its for Delete newVersion?: T; op: CommitType; @@ -70,6 +71,10 @@ export type TSecretApprovalRequest< export type TGetSecretApprovalRequestList = { workspaceId: string; environment?: string; + status?: "open" | "close"; + committer?: string; + limit?: number; + offset?: number; }; export type TGetSecretApprovalRequestDetails = { @@ -81,3 +86,7 @@ export type TUpdateSecretApprovalRequestStatusDTO = { status: ApprovalStatus; id: string; }; + +export type TPerformSecretApprovalRequestMerge = { + id: string; +}; diff --git a/frontend/src/views/SecretApprovalPage/components/SecretApprovalRequest/SecretApprovalRequest.tsx b/frontend/src/views/SecretApprovalPage/components/SecretApprovalRequest/SecretApprovalRequest.tsx index 0600734bb..90924574a 100644 --- a/frontend/src/views/SecretApprovalPage/components/SecretApprovalRequest/SecretApprovalRequest.tsx +++ b/frontend/src/views/SecretApprovalPage/components/SecretApprovalRequest/SecretApprovalRequest.tsx @@ -1,8 +1,21 @@ -import { useState } from "react"; -import { faCheck, faCodeBranch } from "@fortawesome/free-solid-svg-icons"; +import { Fragment, useState } from "react"; +import { + faCheck, + faCheckCircle, + faChevronDown, + faCodeBranch +} from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { AnimatePresence, motion } from "framer-motion"; +import { + Button, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuTrigger +} from "@app/components/v2"; import { useWorkspace } from "@app/context"; import { useGetSecretApprovalRequests, useGetWorkspaceUsers } from "@app/hooks/api"; import { TSecretApprovalRequest, TWorkspaceUser } from "@app/hooks/api/types"; @@ -17,7 +30,22 @@ export const SecretApprovalRequest = () => { const workspaceId = currentWorkspace?._id || ""; const [selectedApproval, setSelectedApproval] = useState(null); - const { data: secretApprovalRequests } = useGetSecretApprovalRequests({ workspaceId }); + // filters + const [statusFilter, setStatusFilter] = useState<"open" | "close">("open"); + const [envFilter, setEnvFilter] = useState(); + const [committerFilter, setCommitterFilter] = useState(); + + const { + data: secretApprovalRequests, + isFetchingNextPage: isFetchingNextApprovalRequest, + fetchNextPage: fetchNextApprovalRequest, + hasNextPage: hasNextApprovalPage + } = useGetSecretApprovalRequests({ + workspaceId, + status: statusFilter, + environment: envFilter, + committer: committerFilter + }); const { data: members } = useGetWorkspaceUsers(workspaceId); const membersGroupById = members?.reduce>( (prev, curr) => ({ ...prev, [curr._id]: curr }), @@ -51,45 +79,129 @@ export const SecretApprovalRequest = () => { initial={{ opacity: 0, translateX: -30 }} animate={{ opacity: 1, translateX: 0 }} exit={{ opacity: 0, translateX: -30 }} - className="rounded-md bg-mineshaft-800 text-gray-300" + className="rounded-md text-gray-300" > -
-
+
+
setStatusFilter("open")} + onKeyDown={(evt) => { + if (evt.key === "Enter") setStatusFilter("open"); + }} + className={statusFilter === "close" ? "text-gray-500" : ""} + > 27 Open
-
+
setStatusFilter("close")} + onKeyDown={(evt) => { + if (evt.key === "Enter") setStatusFilter("close"); + }} + > 27 Closed
+
+ + + + + + Select an environment + {currentWorkspace?.environments.map(({ slug, name }) => ( + setEnvFilter((state) => (state === slug ? undefined : slug))} + key={`request-filter-${slug}`} + icon={envFilter === slug && } + iconPos="right" + > + {name} + + ))} + + + + + + + + Select an author + {members?.map(({ user, _id }) => ( + + setCommitterFilter((state) => (state === _id ? undefined : _id)) + } + key={`request-filter-member-${_id}`} + icon={committerFilter === _id && } + iconPos="right" + > + {user.email} + + ))} + + +
-
- {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 - -
- ); - })} +
+ {secretApprovalRequests?.pages?.map((group, i) => ( + + {group?.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/SecretApprovalRequestAction.tsx b/frontend/src/views/SecretApprovalPage/components/SecretApprovalRequest/components/SecretApprovalRequestAction.tsx new file mode 100644 index 000000000..23cc1a06e --- /dev/null +++ b/frontend/src/views/SecretApprovalPage/components/SecretApprovalRequest/components/SecretApprovalRequestAction.tsx @@ -0,0 +1,47 @@ +import { faCheck, faClose } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { Button } from "@app/components/v2"; + +type Props = { + hasMerged?: boolean; + status: "close" | "open"; + isMergable?: boolean; + isMerging?: boolean; + onMerge: () => void; + onClose?: () => void; +}; +export const SecretApprovalRequestAction = ({ + hasMerged, + status, + isMergable, + onMerge, + isMerging, + onClose +}: Props) => { + if (!hasMerged && status === "open") { + return ( + <> + + + + ); + } + + if (hasMerged && status === "close") return This approval request has been merged; + + return This approval request has been closed; +}; diff --git a/frontend/src/views/SecretApprovalPage/components/SecretApprovalRequest/components/SecretApprovalRequestChangeItem.tsx b/frontend/src/views/SecretApprovalPage/components/SecretApprovalRequest/components/SecretApprovalRequestChangeItem.tsx deleted file mode 100644 index 0fdfbfc94..000000000 --- a/frontend/src/views/SecretApprovalPage/components/SecretApprovalRequest/components/SecretApprovalRequestChangeItem.tsx +++ /dev/null @@ -1 +0,0 @@ -type Props = {}; diff --git a/frontend/src/views/SecretApprovalPage/components/SecretApprovalRequest/components/SecretApprovalRequestChanges.tsx b/frontend/src/views/SecretApprovalPage/components/SecretApprovalRequest/components/SecretApprovalRequestChanges.tsx index b56aec96b..db99aa8a6 100644 --- a/frontend/src/views/SecretApprovalPage/components/SecretApprovalRequest/components/SecretApprovalRequestChanges.tsx +++ b/frontend/src/views/SecretApprovalPage/components/SecretApprovalRequest/components/SecretApprovalRequestChanges.tsx @@ -4,7 +4,6 @@ import { faCheck, faCheckCircle, faCircle, - faClose, faCodeBranch, faXmarkCircle } from "@fortawesome/free-solid-svg-icons"; @@ -25,15 +24,19 @@ import { Tooltip, Tr } from "@app/components/v2"; +import { useUser } from "@app/context"; import { useGetSecretApprovalRequestDetails, useGetUserWsKey, + usePerformSecretApprovalRequestMerge, useUpdateSecretApprovalRequestStatus } from "@app/hooks/api"; import { ApprovalStatus, CommitType, TWorkspaceUser } from "@app/hooks/api/types"; import { useNotificationContext } from "~/components/context/Notifications/NotificationProvider"; +import { SecretApprovalRequestAction } from "./SecretApprovalRequestAction"; + export const generateCommitText = (commits: { op: CommitType }[] = []) => { const score: Record = {}; commits.forEach(({ op }) => { @@ -94,6 +97,7 @@ export const SecretApprovalRequestChanges = ({ members = {} }: Props) => { const { createNotification } = useNotificationContext(); + const { user } = useUser(); const { data: decryptFileKey } = useGetUserWsKey(workspaceId); const { data: secretApprovalRequestDetails, @@ -109,9 +113,17 @@ export const SecretApprovalRequestChanges = ({ isLoading: isUpdatingRequestStatus, variables } = useUpdateSecretApprovalRequestStatus(); + const { mutateAsync: performSecretApprovalMerge, isLoading: isMerging } = + usePerformSecretApprovalRequestMerge(); + const isApproving = variables?.status === ApprovalStatus.APPROVED && isUpdatingRequestStatus; const isRejecting = variables?.status === ApprovalStatus.REJECTED && isUpdatingRequestStatus; + // membership of present user + const myMembership = Object.values(members).find( + ({ user: membershipUser }) => membershipUser.email === user.email + ); + const myMembershipId = myMembership?._id || ""; const reviewedMembers = secretApprovalRequestDetails?.reviewers?.reduce< Record >( @@ -121,6 +133,8 @@ export const SecretApprovalRequestChanges = ({ }), {} ); + const hasApproved = reviewedMembers?.[myMembershipId] === ApprovalStatus.APPROVED; + const hasRejected = reviewedMembers?.[myMembershipId] === ApprovalStatus.REJECTED; const handleSecretApprovalStatusUpdate = async (status: ApprovalStatus) => { try { @@ -128,6 +142,28 @@ export const SecretApprovalRequestChanges = ({ id: approvalRequestId, status }); + createNotification({ + type: "success", + text: `Successfully ${status} the request` + }); + } catch (err) { + console.log(err); + createNotification({ + type: "error", + text: "Failed to update the request status" + }); + } + }; + + const handleSecretApprovalRequestMerge = async () => { + try { + await performSecretApprovalMerge({ + id: approvalRequestId + }); + createNotification({ + type: "success", + text: "Successfully merged the request" + }); } catch (err) { console.log(err); createNotification({ @@ -145,6 +181,13 @@ export const SecretApprovalRequestChanges = ({ if (!isSecretApprovalRequestSuccess) return
Failed
; + const isMergable = + secretApprovalRequestDetails?.policy?.approvals <= + secretApprovalRequestDetails?.policy?.approvers?.filter( + (approverId) => reviewedMembers?.[approverId] === ApprovalStatus.APPROVED + ).length; + const hasMerged = secretApprovalRequestDetails?.hasMerged; + return (
@@ -171,26 +214,26 @@ export const SecretApprovalRequestChanges = ({
- {secretApprovalRequestDetails.commits.map(({ op, secret, newVersion }, index) => ( + {secretApprovalRequestDetails.commits.map(({ op, secretVersion, newVersion }, index) => (
@@ -207,16 +250,16 @@ export const SecretApprovalRequestChanges = ({ - + - + - +
OLD{secret?.key}{secretVersion?.key} - + {secret?.comment}{secretVersion?.comment} - {secret?.tags?.map(({ name, _id: tagId, tagColor }) => ( + {secretVersion?.tags?.map(({ name, _id: tagId, tagColor }) => (
{op === CommitType.CREATE ? newVersion?.secretKey : secret?.key} + {op === CommitType.CREATE ? newVersion?.secretKey : secretVersion?.key} + - {op === CommitType.CREATE ? newVersion?.secretComment : secret?.comment} + {op === CommitType.CREATE + ? newVersion?.secretComment + : secretVersion?.comment} - {(op === CommitType.CREATE ? newVersion?.tags : secret?.tags)?.map( + {(op === CommitType.CREATE ? newVersion?.tags : secretVersion?.tags)?.map( ({ name, _id: tagId, tagColor }) => (
- - +
diff --git a/frontend/src/views/SecretMainPage/SecretMainPage.tsx b/frontend/src/views/SecretMainPage/SecretMainPage.tsx index 60fe31dce..fc7a45fc3 100644 --- a/frontend/src/views/SecretMainPage/SecretMainPage.tsx +++ b/frontend/src/views/SecretMainPage/SecretMainPage.tsx @@ -19,6 +19,7 @@ import { useGetImportedSecrets, useGetProjectFolders, useGetProjectSecrets, + useGetSecretApprovalPolicyOfABoard, useGetSecretImports, useGetUserWsKey, useGetWorkspaceSnapshotList, @@ -118,6 +119,13 @@ export const SecretMainPage = () => { // fetch tags const { data: tags } = useGetWsTags(canReadSecret ? workspaceId : ""); + const { data: boardPolicy } = useGetSecretApprovalPolicyOfABoard({ + workspaceId, + environment, + secretPath + }); + const isProtectedBranch = Boolean(boardPolicy); + const { data: snapshotList, isFetchingNextPage: isFetchingNextSnapshotList, @@ -207,6 +215,7 @@ export const SecretMainPage = () => { secretPath={secretPath} isProjectRelated onEnvChange={handleEnvChange} + isProtectedBranch={isProtectedBranch} />
{!isRollbackMode ? ( @@ -281,6 +290,7 @@ export const SecretMainPage = () => { workspaceId={workspaceId} secretPath={secretPath} decryptFileKey={decryptFileKey!} + isProtectedBranch={isProtectedBranch} /> )} {!canReadSecret && folders?.length === 0 && } @@ -292,6 +302,7 @@ export const SecretMainPage = () => { decryptFileKey={decryptFileKey!} secretPath={secretPath} autoCapitalize={currentWorkspace?.autoCapitalization} + isProtectedBranch={isProtectedBranch} /> { secretPath={secretPath} isSmaller={isNotEmtpy} environments={currentWorkspace?.environments} + isProtectedBranch={isProtectedBranch} /> { const { register, @@ -62,7 +64,9 @@ export const CreateSecretForm = ({ reset(); createNotification({ type: "success", - text: "Successfully created secret" + text: isProtectedBranch + ? "Requested changes have been send for review" + : "Successfully created secret" }); } catch (error) { console.log(error); diff --git a/frontend/src/views/SecretMainPage/components/SecretDropzone/SecretDropzone.tsx b/frontend/src/views/SecretMainPage/components/SecretDropzone/SecretDropzone.tsx index 838a5f0fb..bf3ff4810 100644 --- a/frontend/src/views/SecretMainPage/components/SecretDropzone/SecretDropzone.tsx +++ b/frontend/src/views/SecretMainPage/components/SecretDropzone/SecretDropzone.tsx @@ -43,6 +43,7 @@ type Props = { environment: string; secretPath: string; secrets?: DecryptedSecret[]; + isProtectedBranch?: boolean; }; export const SecretDropzone = ({ @@ -52,7 +53,8 @@ export const SecretDropzone = ({ decryptFileKey, environment, secretPath, - secrets = [] + secrets = [], + isProtectedBranch = false }: Props): JSX.Element => { const { t } = useTranslation(); const [isDragActive, setDragActive] = useToggle(); @@ -195,7 +197,9 @@ export const SecretDropzone = ({ handlePopUpClose("overlapKeyWarning"); createNotification({ type: "success", - text: "Successfully uploaded secrets" + text: isProtectedBranch + ? "Uploaded changes have been send for review" + : "Successfully uploaded secrets" }); } catch (err) { console.log(err); diff --git a/frontend/src/views/SecretMainPage/components/SecretListView/SecretDetaiSidebar.tsx b/frontend/src/views/SecretMainPage/components/SecretListView/SecretDetaiSidebar.tsx index a6bdd763c..0a9f38bc4 100644 --- a/frontend/src/views/SecretMainPage/components/SecretListView/SecretDetaiSidebar.tsx +++ b/frontend/src/views/SecretMainPage/components/SecretListView/SecretDetaiSidebar.tsx @@ -49,7 +49,8 @@ type Props = { onDeleteSecret: () => void; onSaveSecret: ( orgSec: DecryptedSecret, - modSec: Omit & { tags: { _id: string }[] } + modSec: Omit & { tags: { _id: string }[] }, + cb?: () => void ) => Promise; tags: WsTag[]; onCreateTag: () => void; @@ -143,7 +144,7 @@ export const SecretDetailSidebar = ({ }; const handleFormSubmit = async (data: TFormSchema) => { - await onSaveSecret(secret, { ...secret, ...data }); + await onSaveSecret(secret, { ...secret, ...data }, () => reset()); }; return ( diff --git a/frontend/src/views/SecretMainPage/components/SecretListView/SecretItem.tsx b/frontend/src/views/SecretMainPage/components/SecretListView/SecretItem.tsx index a2eb5eb31..c420cdd07 100644 --- a/frontend/src/views/SecretMainPage/components/SecretListView/SecretItem.tsx +++ b/frontend/src/views/SecretMainPage/components/SecretListView/SecretItem.tsx @@ -54,7 +54,8 @@ type Props = { secret: DecryptedSecret; onSaveSecret: ( orgSec: DecryptedSecret, - modSec: Omit & { tags: { _id: string }[] } + modSec: Omit & { tags: { _id: string }[] }, + cb?: () => void ) => Promise; onDeleteSecret: (sec: DecryptedSecret) => void; onDetailViewSecret: (sec: DecryptedSecret) => void; @@ -148,13 +149,14 @@ export const SecretItem = memo( ); setValue("valueOverride", secret?.valueOverride, { shouldDirty: !isUnsavedOverride }); } else { + reset(); setValue("overrideAction", SecretActionType.Modified, { shouldDirty: true }); setValue("valueOverride", "", { shouldDirty: true }); } }; const handleFormSubmit = async (data: TFormSchema) => { - await onSaveSecret(secret, { ...secret, ...data }); + await onSaveSecret(secret, { ...secret, ...data }, () => reset()); }; const handleTagSelect = (tag: WsTag) => { diff --git a/frontend/src/views/SecretMainPage/components/SecretListView/SecretListView.tsx b/frontend/src/views/SecretMainPage/components/SecretListView/SecretListView.tsx index 050480456..cbeea3101 100644 --- a/frontend/src/views/SecretMainPage/components/SecretListView/SecretListView.tsx +++ b/frontend/src/views/SecretMainPage/components/SecretListView/SecretListView.tsx @@ -27,6 +27,7 @@ type Props = { sortDir?: SortDir; tags?: WsTag[]; isVisible?: boolean; + isProtectedBranch?: boolean; }; const reorderSecretGroupByUnderscore = (secrets: DecryptedSecret[], sortDir: SortDir) => { @@ -84,7 +85,8 @@ export const SecretListView = ({ filter, sortDir = SortDir.ASC, tags: wsTags = [], - isVisible + isVisible, + isProtectedBranch = false }: Props) => { const { createNotification } = useNotificationContext(); const queryClient = useQueryClient(); @@ -178,8 +180,10 @@ export const SecretListView = ({ const handleSaveSecret = useCallback( async ( orgSecret: DecryptedSecret, - modSecret: Omit & { tags: { _id: string }[] } + modSecret: Omit & { tags: { _id: string }[] }, + cb?: () => void ) => { + console.log(orgSecret, modSecret); const { key: oldKey } = orgSecret; const { key, value, overrideAction, idOverride, valueOverride, tags, comment } = modSecret; const hasKeyChanged = oldKey !== key; @@ -193,6 +197,19 @@ export const SecretListView = ({ ) && isSameTags; try { + // personal secret change + if (overrideAction === "deleted") { + await handleSecretOperation("delete", "personal", oldKey); + } else if (overrideAction && idOverride) { + await handleSecretOperation("update", "personal", oldKey, { + value: valueOverride, + newKey: hasKeyChanged ? key : undefined, + skipMultilineEncoding: modSecret.skipMultilineEncoding + }); + } else if (overrideAction) { + await handleSecretOperation("create", "personal", oldKey, { value: valueOverride }); + } + // shared secret change if (!isSharedSecUnchanged) { await handleSecretOperation("update", "shared", oldKey, { @@ -202,19 +219,7 @@ export const SecretListView = ({ newKey: hasKeyChanged ? key : undefined, skipMultilineEncoding: modSecret.skipMultilineEncoding }); - } - - // personal secret change - if (overrideAction === "deleted") { - await handleSecretOperation("delete", "personal", key); - } else if (overrideAction && idOverride) { - await handleSecretOperation("update", "personal", oldKey, { - value: valueOverride, - newKey: hasKeyChanged ? key : undefined, - skipMultilineEncoding: modSecret.skipMultilineEncoding - }); - } else if (overrideAction) { - await handleSecretOperation("create", "personal", key, { value: valueOverride }); + if (isProtectedBranch) cb?.(); } queryClient.invalidateQueries( @@ -229,7 +234,9 @@ export const SecretListView = ({ handlePopUpClose("secretDetail"); createNotification({ type: "success", - text: "Successfully saved secrets" + text: isProtectedBranch + ? "Requested changes have been sent for review" + : "Successfully saved secrets" }); } catch (error) { console.log(error); @@ -259,7 +266,9 @@ export const SecretListView = ({ handlePopUpClose("secretDetail"); createNotification({ type: "success", - text: "Successfully deleted secret" + text: isProtectedBranch + ? "Requested changes have been sent for review" + : "Successfully deleted secret" }); } catch (error) { console.log(error); From 04c7d49477f6f3ab07dc6b072fb39c11e2fdb9af Mon Sep 17 00:00:00 2001 From: Akhil Mohan Date: Tue, 3 Oct 2023 16:36:44 +0530 Subject: [PATCH 03/12] feat(secret-approval): resolved infinite query bug and added support for closing, re-opening request, stale req ui --- .../v1/secretApprovalRequestsController.ts | 63 ++++- backend/src/models/secretApprovalRequest.ts | 2 + .../src/routes/v1/secretApprovalRequest.ts | 12 +- backend/src/services/SecretApprovalService.ts | 9 +- .../src/validation/secretApprovalRequest.ts | 13 +- frontend/src/components/v2/Button/Button.tsx | 9 + .../hooks/api/secretApprovalRequest/index.tsx | 3 +- .../api/secretApprovalRequest/mutation.tsx | 31 ++- .../hooks/api/secretApprovalRequest/types.ts | 9 +- frontend/src/layouts/AppLayout/AppLayout.tsx | 4 +- .../SecretApprovalRequest.tsx | 33 +-- .../SecretApprovalRequestAction.tsx | 167 ++++++++++--- .../SecretApprovalRequestChangeItem.tsx | 154 ++++++++++++ .../SecretApprovalRequestChanges.tsx | 221 +++++------------- .../SecretListView/SecretListView.tsx | 3 +- 15 files changed, 500 insertions(+), 233 deletions(-) create mode 100644 frontend/src/views/SecretApprovalPage/components/SecretApprovalRequest/components/SecretApprovalRequestChangeItem.tsx diff --git a/backend/src/controllers/v1/secretApprovalRequestsController.ts b/backend/src/controllers/v1/secretApprovalRequestsController.ts index c35d65866..47fb43fa2 100644 --- a/backend/src/controllers/v1/secretApprovalRequestsController.ts +++ b/backend/src/controllers/v1/secretApprovalRequestsController.ts @@ -35,7 +35,7 @@ export const getSecretApprovalRequests = async (req: Request, res: Response) => .skip(offset) .populate("policy") .lean(); - if (!approvalRequests.length) return res.send({ requests: [] }); + if (!approvalRequests.length) return res.send({ approvals: [] }); const unqiueEnvs = environment ?? { $in: [...new Set(approvalRequests.map(({ environment }) => environment))] @@ -64,7 +64,7 @@ export const getSecretApprovalRequestDetails = async (req: Request, res: Respons params: { id } } = await validateRequest(reqValidator.getSecretApprovalRequestDetails, req); const secretApprovalRequest = await SecretApprovalRequest.findById(id) - .populate("policy") + .populate<{ policy: ISecretApprovalPolicy }>("policy") .populate({ path: "commits.secretVersion", populate: { @@ -84,7 +84,9 @@ export const getSecretApprovalRequestDetails = async (req: Request, res: Respons if ( membership.role !== "admin" && secretApprovalRequest.committer !== membership.id && - secretApprovalRequest.reviewers.find(({ member }) => member === membership.id) + !secretApprovalRequest.policy.approvers.find( + (approverId) => approverId.toString() === membership._id.toString() + ) ) { throw UnauthorizedRequestError({ message: "User has no access" }); } @@ -94,11 +96,11 @@ export const getSecretApprovalRequestDetails = async (req: Request, res: Respons }); }; -export const updateSecretApprovalRequestStatus = async (req: Request, res: Response) => { +export const updateSecretApprovalReviewStatus = async (req: Request, res: Response) => { const { body: { status }, params: { id } - } = await validateRequest(reqValidator.updateSecretApprovalRequestStatus, req); + } = await validateRequest(reqValidator.updateSecretApprovalReviewStatus, req); const secretApprovalRequest = await SecretApprovalRequest.findById(id).populate<{ policy: ISecretApprovalPolicy; }>("policy"); @@ -132,7 +134,7 @@ export const updateSecretApprovalRequestStatus = async (req: Request, res: Respo export const mergeSecretApprovalRequest = async (req: Request, res: Response) => { const { - body: { id } + params: { id } } = await validateRequest(reqValidator.mergeSecretApprovalRequest, req); const secretApprovalRequest = await SecretApprovalRequest.findById(id).populate<{ @@ -149,7 +151,7 @@ export const mergeSecretApprovalRequest = async (req: Request, res: Response) => if ( membership.role !== "admin" && secretApprovalRequest.committer !== membership.id && - !secretApprovalRequest.policy.approvers.find((approverId) => approverId === membership.id) + !secretApprovalRequest.policy.approvers.find((approverId) => approverId.equals(membership.id)) ) { throw UnauthorizedRequestError({ message: "User has no access" }); } @@ -166,6 +168,51 @@ export const mergeSecretApprovalRequest = async (req: Request, res: Response) => if (!hasMinApproval) throw BadRequestError({ message: "Doesn't have minimum approvals needed" }); - const approval = await performSecretApprovalRequestMerge(id, req.authData); + const approval = await performSecretApprovalRequestMerge( + id, + req.authData, + membership._id.toString() + ); return res.send({ approval }); }; + +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.equals(membership._id)) + ) { + throw UnauthorizedRequestError({ message: "User has no access" }); + } + + if (secretApprovalRequest.hasMerged) + throw BadRequestError({ message: "Approval request has been merged" }); + if (secretApprovalRequest.status === "close" && status === "close") + throw BadRequestError({ message: "Approval request is already closed" }); + if (secretApprovalRequest.status === "open" && status === "open") + throw BadRequestError({ message: "Approval request is already open" }); + + const updatedRequest = await SecretApprovalRequest.findByIdAndUpdate( + id, + { status, statusChangeBy: membership._id }, + { new: true } + ); + return res.send({ approval: updatedRequest }); +}; diff --git a/backend/src/models/secretApprovalRequest.ts b/backend/src/models/secretApprovalRequest.ts index 9459e8189..d05d120f5 100644 --- a/backend/src/models/secretApprovalRequest.ts +++ b/backend/src/models/secretApprovalRequest.ts @@ -54,6 +54,7 @@ export type ISecretCommits = Array< export interface ISecretApprovalRequest { _id: Types.ObjectId; committer: Types.ObjectId; + statusChangeBy: Types.ObjectId; reviewers: { member: Types.ObjectId; status: ApprovalStatus; @@ -159,6 +160,7 @@ const secretApprovalRequestSchema = new Schema( hasMerged: { type: Boolean, default: false }, status: { type: String, enum: ["close", "open"], default: "open" }, committer: { type: Schema.Types.ObjectId, ref: "Membership" }, + statusChangeBy: { type: Schema.Types.ObjectId, ref: "Membership" }, commits: [ { secret: { type: Types.ObjectId, ref: "Secret" }, diff --git a/backend/src/routes/v1/secretApprovalRequest.ts b/backend/src/routes/v1/secretApprovalRequest.ts index b7737411b..e53e425ff 100644 --- a/backend/src/routes/v1/secretApprovalRequest.ts +++ b/backend/src/routes/v1/secretApprovalRequest.ts @@ -21,7 +21,7 @@ router.get( ); router.post( - "/merge", + "/:id/merge", requireAuth({ acceptedAuthModes: [AuthMode.JWT] }), @@ -29,7 +29,15 @@ router.post( ); router.post( - "/:id", + "/:id/review", + requireAuth({ + acceptedAuthModes: [AuthMode.JWT] + }), + secretApprovalRequestController.updateSecretApprovalReviewStatus +); + +router.post( + "/:id/status", requireAuth({ acceptedAuthModes: [AuthMode.JWT] }), diff --git a/backend/src/services/SecretApprovalService.ts b/backend/src/services/SecretApprovalService.ts index 3f02d57a4..4e4ba5bb7 100644 --- a/backend/src/services/SecretApprovalService.ts +++ b/backend/src/services/SecretApprovalService.ts @@ -317,7 +317,11 @@ export const generateSecretApprovalRequest = async ({ }; // validation for a merge conditions happen in another function in controller -export const performSecretApprovalRequestMerge = async (id: string, authData: AuthData) => { +export const performSecretApprovalRequestMerge = async ( + id: string, + authData: AuthData, + userMembershipId: string +) => { const secretApprovalRequest = await SecretApprovalRequest.findById(id) .populate<{ commits: ISecretCommits }>({ path: "commits.secret", @@ -636,7 +640,8 @@ export const performSecretApprovalRequestMerge = async (id: string, authData: Au { conflicts, hasMerged: true, - status: "close" + status: "close", + statusChangeBy: userMembershipId }, { new: true } ); diff --git a/backend/src/validation/secretApprovalRequest.ts b/backend/src/validation/secretApprovalRequest.ts index 0f8e66277..93bc68267 100644 --- a/backend/src/validation/secretApprovalRequest.ts +++ b/backend/src/validation/secretApprovalRequest.ts @@ -18,7 +18,7 @@ export const getSecretApprovalRequestDetails = z.object({ }) }); -export const updateSecretApprovalRequestStatus = z.object({ +export const updateSecretApprovalReviewStatus = z.object({ body: z.object({ status: z.enum([ApprovalStatus.APPROVED, ApprovalStatus.REJECTED]) }), @@ -28,7 +28,16 @@ export const updateSecretApprovalRequestStatus = z.object({ }); export const mergeSecretApprovalRequest = z.object({ - body: z.object({ + params: z.object({ id: z.string().trim() }) }); + +export const updateSecretApprovalRequestStatus = z.object({ + params: z.object({ + id: z.string().trim() + }), + body: z.object({ + status: z.enum(["open", "close"]) + }) +}); diff --git a/frontend/src/components/v2/Button/Button.tsx b/frontend/src/components/v2/Button/Button.tsx index 7c2a5fd6d..ab72c0e51 100644 --- a/frontend/src/components/v2/Button/Button.tsx +++ b/frontend/src/components/v2/Button/Button.tsx @@ -11,6 +11,9 @@ type Props = { isLoading?: boolean; }; +// refactor(akhilmhdh): both color and size variants are together need to split it +// colorSchema should handle all color class names +// variant should handle how the button padding and other types should be set const buttonVariants = cva( [ "button", @@ -106,6 +109,12 @@ const buttonVariants = cva( variant: "outline", className: "text-red hover:bg-red hover:text-black" }, + { + colorSchema: "danger", + variant: "outline_bg", + className: + "bg-mineshaft-600 border border-red-500 hover:bg-red/[0.1] hover:border-red/40 text-red-500" + }, { colorSchema: "primary", variant: "plain", diff --git a/frontend/src/hooks/api/secretApprovalRequest/index.tsx b/frontend/src/hooks/api/secretApprovalRequest/index.tsx index 26b5168f5..58391d2ac 100644 --- a/frontend/src/hooks/api/secretApprovalRequest/index.tsx +++ b/frontend/src/hooks/api/secretApprovalRequest/index.tsx @@ -1,5 +1,6 @@ export { usePerformSecretApprovalRequestMerge, - useUpdateSecretApprovalRequestStatus + useUpdateSecretApprovalRequestStatus, + useUpdateSecretApprovalReviewStatus } 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 index 8ea0335ab..bc17810d1 100644 --- a/frontend/src/hooks/api/secretApprovalRequest/mutation.tsx +++ b/frontend/src/hooks/api/secretApprovalRequest/mutation.tsx @@ -3,14 +3,36 @@ import { useMutation, useQueryClient } from "@tanstack/react-query"; import { apiRequest } from "@app/config/request"; import { secretApprovalRequestKeys } from "./queries"; -import { TPerformSecretApprovalRequestMerge, TUpdateSecretApprovalRequestStatusDTO } from "./types"; +import { + TPerformSecretApprovalRequestMerge, + TUpdateSecretApprovalRequestStatusDTO, + TUpdateSecretApprovalReviewStatusDTO +} from "./types"; + +export const useUpdateSecretApprovalReviewStatus = () => { + const queryClient = useQueryClient(); + + return useMutation<{}, {}, TUpdateSecretApprovalReviewStatusDTO>({ + mutationFn: async ({ id, status }) => { + const { data } = await apiRequest.post(`/api/v1/secret-approval-requests/${id}/review`, { + status + }); + return data; + }, + onSuccess: (_, { id }) => { + queryClient.invalidateQueries(secretApprovalRequestKeys.detail({ id })); + } + }); +}; 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 }); + const { data } = await apiRequest.post(`/api/v1/secret-approval-requests/${id}/status`, { + status + }); return data; }, onSuccess: (_, { id }) => { @@ -24,11 +46,12 @@ export const usePerformSecretApprovalRequestMerge = () => { return useMutation<{}, {}, TPerformSecretApprovalRequestMerge>({ mutationFn: async ({ id }) => { - const { data } = await apiRequest.post("/api/v1/secret-approval-requests/merge", { id }); + const { data } = await apiRequest.post(`/api/v1/secret-approval-requests/${id}/merge`); return data; }, - onSuccess: (_, { id }) => { + onSuccess: (_, { id, workspaceId }) => { queryClient.invalidateQueries(secretApprovalRequestKeys.detail({ id })); + queryClient.invalidateQueries(secretApprovalRequestKeys.list({ workspaceId })); } }); }; diff --git a/frontend/src/hooks/api/secretApprovalRequest/types.ts b/frontend/src/hooks/api/secretApprovalRequest/types.ts index f1ea1ad90..c73394570 100644 --- a/frontend/src/hooks/api/secretApprovalRequest/types.ts +++ b/frontend/src/hooks/api/secretApprovalRequest/types.ts @@ -58,6 +58,7 @@ export type TSecretApprovalRequest< hasMerged: boolean; status: "open" | "close"; policy: TSecretApprovalPolicy; + statusChangeBy: string; commits: { // if there is no secret means it was creation secret?: { version: number }; @@ -82,11 +83,17 @@ export type TGetSecretApprovalRequestDetails = { decryptKey: UserWsKeyPair; }; -export type TUpdateSecretApprovalRequestStatusDTO = { +export type TUpdateSecretApprovalReviewStatusDTO = { status: ApprovalStatus; id: string; }; +export type TUpdateSecretApprovalRequestStatusDTO = { + status: "open" | "close"; + id: string; +}; + export type TPerformSecretApprovalRequestMerge = { id: string; + workspaceId: string; }; diff --git a/frontend/src/layouts/AppLayout/AppLayout.tsx b/frontend/src/layouts/AppLayout/AppLayout.tsx index e9a66ead2..b86b90b7c 100644 --- a/frontend/src/layouts/AppLayout/AppLayout.tsx +++ b/frontend/src/layouts/AppLayout/AppLayout.tsx @@ -482,11 +482,11 @@ export const AppLayout = ({ children }: LayoutProps) => { - Admin Panel + Secret Approval diff --git a/frontend/src/views/SecretApprovalPage/components/SecretApprovalRequest/SecretApprovalRequest.tsx b/frontend/src/views/SecretApprovalPage/components/SecretApprovalRequest/SecretApprovalRequest.tsx index 90924574a..30af811d9 100644 --- a/frontend/src/views/SecretApprovalPage/components/SecretApprovalRequest/SecretApprovalRequest.tsx +++ b/frontend/src/views/SecretApprovalPage/components/SecretApprovalRequest/SecretApprovalRequest.tsx @@ -39,7 +39,8 @@ export const SecretApprovalRequest = () => { data: secretApprovalRequests, isFetchingNextPage: isFetchingNextApprovalRequest, fetchNextPage: fetchNextApprovalRequest, - hasNextPage: hasNextApprovalPage + hasNextPage: hasNextApprovalPage, + refetch } = useGetSecretApprovalRequests({ workspaceId, status: statusFilter, @@ -51,9 +52,13 @@ export const SecretApprovalRequest = () => { (prev, curr) => ({ ...prev, [curr._id]: curr }), {} ); - const isSecretApprovalScreen = Boolean(selectedApproval); + const handleGoBackSecretRequestDetail = () => { + setSelectedApproval(null); + refetch({ refetchPage: (_page, index) => index === 0 }); + }; + return ( {isSecretApprovalScreen ? ( @@ -68,7 +73,7 @@ export const SecretApprovalRequest = () => { workspaceId={workspaceId} members={membersGroupById} approvalRequestId={selectedApproval?._id || ""} - onGoBack={() => setSelectedApproval(null)} + onGoBack={handleGoBackSecretRequestDetail} committer={membersGroupById?.[selectedApproval?.committer || ""]} /> @@ -192,16 +197,18 @@ export const SecretApprovalRequest = () => { ))} - + {hasNextApprovalPage && ( + + )} )} diff --git a/frontend/src/views/SecretApprovalPage/components/SecretApprovalRequest/components/SecretApprovalRequestAction.tsx b/frontend/src/views/SecretApprovalPage/components/SecretApprovalRequest/components/SecretApprovalRequestAction.tsx index 23cc1a06e..4a9f1fa10 100644 --- a/frontend/src/views/SecretApprovalPage/components/SecretApprovalRequest/components/SecretApprovalRequestAction.tsx +++ b/frontend/src/views/SecretApprovalPage/components/SecretApprovalRequest/components/SecretApprovalRequestAction.tsx @@ -1,47 +1,160 @@ -import { faCheck, faClose } from "@fortawesome/free-solid-svg-icons"; +import { + faCheck, + faClose, + faLockOpen, + faSquareCheck, + faSquareXmark, + faUserLock +} from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { twMerge } from "tailwind-merge"; +import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider"; import { Button } from "@app/components/v2"; +import { + usePerformSecretApprovalRequestMerge, + useUpdateSecretApprovalRequestStatus +} from "@app/hooks/api"; type Props = { + approvalRequestId: string; hasMerged?: boolean; - status: "close" | "open"; isMergable?: boolean; - isMerging?: boolean; - onMerge: () => void; - onClose?: () => void; + status: "close" | "open"; + approvals: number; + statusChangeByEmail: string; + workspaceId: string; }; + export const SecretApprovalRequestAction = ({ + approvalRequestId, hasMerged, status, isMergable, - onMerge, - isMerging, - onClose + approvals, + statusChangeByEmail, + workspaceId }: Props) => { + const { createNotification } = useNotificationContext(); + const { mutateAsync: performSecretApprovalMerge, isLoading: isMerging } = + usePerformSecretApprovalRequestMerge(); + + const { mutateAsync: updateSecretStatusChange, isLoading: isStatusChanging } = + useUpdateSecretApprovalRequestStatus(); + + const handleSecretApprovalRequestMerge = async () => { + try { + await performSecretApprovalMerge({ + id: approvalRequestId, + workspaceId + }); + createNotification({ + type: "success", + text: "Successfully merged the request" + }); + } catch (err) { + console.log(err); + createNotification({ + type: "error", + text: "Failed to update the request status" + }); + } + }; + + const handleSecretApprovalStatusChange = async (reqState: "open" | "close") => { + try { + await updateSecretStatusChange({ + id: approvalRequestId, + status: reqState + }); + createNotification({ + type: "success", + text: "Successfully updated the request" + }); + } catch (err) { + console.log(err); + createNotification({ + type: "error", + text: "Failed to update the request status" + }); + } + }; + if (!hasMerged && status === "open") { return ( - <> - - - +
+
+ + + {isMergable ? "Good to merge" : "Review required"} + + At least {approvals} approving review required + {Boolean(statusChangeByEmail) && `. Reopened by ${statusChangeByEmail}`} + + +
+
+ + +
+
); } - if (hasMerged && status === "close") return This approval request has been merged; + if (hasMerged && status === "close") + return ( +
+
+ + + Change request merged + + Merged by {statusChangeByEmail} + + +
+
+ ); - return This approval request has been closed; + return ( +
+
+ + + Change request has been closed + + Closed by {statusChangeByEmail} + + +
+
+ +
+
+ ); }; 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..a566eca21 --- /dev/null +++ b/frontend/src/views/SecretApprovalPage/components/SecretApprovalRequest/components/SecretApprovalRequestChangeItem.tsx @@ -0,0 +1,154 @@ +import { faFilePen } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { + SecretInput, + Table, + TableContainer, + Tag, + TBody, + Td, + Th, + THead, + Tr +} from "@app/components/v2"; +import { CommitType, DecryptedSecret, TSecretApprovalSecChange, WsTag } from "@app/hooks/api/types"; + +export type Props = { + op: CommitType; + secretVersion?: DecryptedSecret; + newVersion?: Omit & { tags?: WsTag[] }; + presentSecretVersionNumber: number; +}; + +const generateItemTitle = (op: CommitType) => { + let text = { label: "", color: "" }; + if (op === CommitType.CREATE) text = { label: "create", color: "#16a34a" }; + else if (op === CommitType.UPDATE) text = { label: "change", color: "#ea580c" }; + else text = { label: "deletion", color: "#b91c1c" }; + + return ( + + Request for secret {text.label} + + ); +}; + +export const SecretApprovalRequestChangeItem = ({ + op, + secretVersion, + newVersion, + presentSecretVersionNumber +}: Props) => { + // meaning request has changed + const isStale = (secretVersion?.version || 1) < presentSecretVersionNumber; + return ( +
+
+
{generateItemTitle(op)}
+ {isStale && ( +
+ + Secret has been changed(stale) +
+ )} +
+ + + + + {op === CommitType.UPDATE && + + + + + + {op === CommitType.UPDATE ? ( + + + + + + + + + + + + + + + + + ) : ( + + + + + + + + + )} +
} + SecretValueCommentTags
OLD{secretVersion?.key} + + {secretVersion?.comment} + {secretVersion?.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 : secretVersion?.key} + + + {op === CommitType.CREATE ? newVersion?.secretComment : secretVersion?.comment} + + {(op === CommitType.CREATE ? newVersion?.tags : secretVersion?.tags)?.map( + ({ name, _id: tagId, tagColor }) => ( + +
+
{name}
+ + ) + )} +
+
+
+ ); +}; diff --git a/frontend/src/views/SecretApprovalPage/components/SecretApprovalRequest/components/SecretApprovalRequestChanges.tsx b/frontend/src/views/SecretApprovalPage/components/SecretApprovalRequest/components/SecretApprovalRequestChanges.tsx index db99aa8a6..1a361dddc 100644 --- a/frontend/src/views/SecretApprovalPage/components/SecretApprovalRequest/components/SecretApprovalRequestChanges.tsx +++ b/frontend/src/views/SecretApprovalPage/components/SecretApprovalRequest/components/SecretApprovalRequestChanges.tsx @@ -9,33 +9,18 @@ import { } 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 { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider"; +import { Button, ContentLoader, EmptyState, IconButton, Tooltip } from "@app/components/v2"; import { useUser } from "@app/context"; import { useGetSecretApprovalRequestDetails, useGetUserWsKey, - usePerformSecretApprovalRequestMerge, - useUpdateSecretApprovalRequestStatus + useUpdateSecretApprovalReviewStatus } from "@app/hooks/api"; import { ApprovalStatus, CommitType, TWorkspaceUser } from "@app/hooks/api/types"; -import { useNotificationContext } from "~/components/context/Notifications/NotificationProvider"; - import { SecretApprovalRequestAction } from "./SecretApprovalRequestAction"; +import { SecretApprovalRequestChangeItem } from "./SecretApprovalRequestChangeItem"; export const generateCommitText = (commits: { op: CommitType }[] = []) => { const score: Record = {}; @@ -112,9 +97,7 @@ export const SecretApprovalRequestChanges = ({ mutateAsync: updateSecretApprovalRequestStatus, isLoading: isUpdatingRequestStatus, variables - } = useUpdateSecretApprovalRequestStatus(); - const { mutateAsync: performSecretApprovalMerge, isLoading: isMerging } = - usePerformSecretApprovalRequestMerge(); + } = useUpdateSecretApprovalReviewStatus(); const isApproving = variables?.status === ApprovalStatus.APPROVED && isUpdatingRequestStatus; const isRejecting = variables?.status === ApprovalStatus.REJECTED && isUpdatingRequestStatus; @@ -155,31 +138,18 @@ export const SecretApprovalRequestChanges = ({ } }; - const handleSecretApprovalRequestMerge = async () => { - try { - await performSecretApprovalMerge({ - id: approvalRequestId - }); - createNotification({ - type: "success", - text: "Successfully merged the request" - }); - } catch (err) { - console.log(err); - createNotification({ - type: "error", - text: "Failed to update the request status" - }); - } - }; - if (isSecretApprovalRequestLoading) {
; } - if (!isSecretApprovalRequestSuccess) return
Failed
; + if (!isSecretApprovalRequestSuccess) + return ( +
+ +
+ ); const isMergable = secretApprovalRequestDetails?.policy?.approvals <= @@ -212,141 +182,54 @@ export const SecretApprovalRequestChanges = ({ - - + {!hasMerged && secretApprovalRequestDetails.status === "open" && ( + <> + + + + )}
- {secretApprovalRequestDetails.commits.map(({ op, secretVersion, newVersion }, index) => ( -
- - - - - {op === CommitType.UPDATE && - - - - - - {op === CommitType.UPDATE ? ( - - - - - - - - - - - - - - - - - ) : ( - - - - - - - - - )} -
} - SecretValueCommentTags
OLD{secretVersion?.key} - - {secretVersion?.comment} - {secretVersion?.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 : secretVersion?.key} - - - - {op === CommitType.CREATE - ? newVersion?.secretComment - : secretVersion?.comment} - - {(op === CommitType.CREATE ? newVersion?.tags : secretVersion?.tags)?.map( - ({ name, _id: tagId, tagColor }) => ( - -
-
{name}
- - ) - )} -
-
-
- ))} + {secretApprovalRequestDetails.commits.map( + ({ op, secretVersion, secret, newVersion }, index) => ( + + ) + )}
-
+
diff --git a/frontend/src/views/SecretMainPage/components/SecretListView/SecretListView.tsx b/frontend/src/views/SecretMainPage/components/SecretListView/SecretListView.tsx index cbeea3101..7ea9e24c4 100644 --- a/frontend/src/views/SecretMainPage/components/SecretListView/SecretListView.tsx +++ b/frontend/src/views/SecretMainPage/components/SecretListView/SecretListView.tsx @@ -183,7 +183,6 @@ export const SecretListView = ({ modSecret: Omit & { tags: { _id: string }[] }, cb?: () => void ) => { - console.log(orgSecret, modSecret); const { key: oldKey } = orgSecret; const { key, value, overrideAction, idOverride, valueOverride, tags, comment } = modSecret; const hasKeyChanged = oldKey !== key; @@ -219,7 +218,7 @@ export const SecretListView = ({ newKey: hasKeyChanged ? key : undefined, skipMultilineEncoding: modSecret.skipMultilineEncoding }); - if (isProtectedBranch) cb?.(); + if (isProtectedBranch) cb(); } queryClient.invalidateQueries( From 4fd227c85f3b2d857e049ebe654715da5941cb18 Mon Sep 17 00:00:00 2001 From: Akhil Mohan Date: Tue, 3 Oct 2023 17:02:32 +0530 Subject: [PATCH 04/12] feat(secret-approval): added loading and empty states for request list --- .../api/secretApprovalRequest/queries.tsx | 2 ++ .../SecretApprovalRequest.tsx | 33 +++++++++++++++++-- 2 files changed, 32 insertions(+), 3 deletions(-) diff --git a/frontend/src/hooks/api/secretApprovalRequest/queries.tsx b/frontend/src/hooks/api/secretApprovalRequest/queries.tsx index 929a579f8..dfe82ee43 100644 --- a/frontend/src/hooks/api/secretApprovalRequest/queries.tsx +++ b/frontend/src/hooks/api/secretApprovalRequest/queries.tsx @@ -142,6 +142,8 @@ export const useGetSecretApprovalRequests = ({ }), enabled: Boolean(workspaceId) && (options?.enabled ?? true), getNextPageParam: (lastPage, pages) => { + if (lastPage.length && lastPage.length < limit) return undefined; + return lastPage?.length !== 0 ? pages.length * limit : undefined; } }); diff --git a/frontend/src/views/SecretApprovalPage/components/SecretApprovalRequest/SecretApprovalRequest.tsx b/frontend/src/views/SecretApprovalPage/components/SecretApprovalRequest/SecretApprovalRequest.tsx index 30af811d9..8929e7ea7 100644 --- a/frontend/src/views/SecretApprovalPage/components/SecretApprovalRequest/SecretApprovalRequest.tsx +++ b/frontend/src/views/SecretApprovalPage/components/SecretApprovalRequest/SecretApprovalRequest.tsx @@ -14,7 +14,9 @@ import { DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, - DropdownMenuTrigger + DropdownMenuTrigger, + EmptyState, + Skeleton } from "@app/components/v2"; import { useWorkspace } from "@app/context"; import { useGetSecretApprovalRequests, useGetWorkspaceUsers } from "@app/hooks/api"; @@ -40,6 +42,7 @@ export const SecretApprovalRequest = () => { isFetchingNextPage: isFetchingNextApprovalRequest, fetchNextPage: fetchNextApprovalRequest, hasNextPage: hasNextApprovalPage, + isLoading: isApprovalRequestLoading, refetch } = useGetSecretApprovalRequests({ workspaceId, @@ -59,6 +62,9 @@ export const SecretApprovalRequest = () => { refetch({ refetchPage: (_page, index) => index === 0 }); }; + const isRequestListEmpty = + !isApprovalRequestLoading && secretApprovalRequests?.pages[0]?.length === 0; + return ( {isSecretApprovalScreen ? ( @@ -97,7 +103,7 @@ export const SecretApprovalRequest = () => { className={statusFilter === "close" ? "text-gray-500" : ""} > - 27 Open + Open
{ }} > - 27 Closed + Closed
@@ -167,6 +173,11 @@ export const SecretApprovalRequest = () => {
+ {isRequestListEmpty && ( +
+ +
+ )} {secretApprovalRequests?.pages?.map((group, i) => ( {group?.map((secretApproval) => { @@ -196,6 +207,22 @@ export const SecretApprovalRequest = () => { })} ))} + {(isFetchingNextApprovalRequest || isApprovalRequestLoading) && ( +
+ {Array.apply(0, Array(3)).map((_x, index) => ( +
+
+ + +
+ +
+ ))} +
+ )}
{hasNextApprovalPage && (