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);