Merge pull request #1044 from akhilmhdh/feat/secret-approval-part-2

Policy based secret review system
This commit is contained in:
Maidul Islam
2023-10-05 15:06:41 -04:00
committed by GitHub
57 changed files with 3398 additions and 372 deletions
+1 -4
View File
@@ -16,8 +16,6 @@ import * as workspaceController from "./workspaceController";
import * as secretScanningController from "./secretScanningController"; import * as secretScanningController from "./secretScanningController";
import * as webhookController from "./webhookController"; import * as webhookController from "./webhookController";
import * as secretImpsController from "./secretImpsController"; import * as secretImpsController from "./secretImpsController";
import * as secretApprovalPolicyController from "./secretApprovalPolicyController";
export { export {
authController, authController,
botController, botController,
@@ -36,6 +34,5 @@ export {
workspaceController, workspaceController,
secretScanningController, secretScanningController,
webhookController, webhookController,
secretImpsController, secretImpsController
secretApprovalPolicyController
}; };
+178 -30
View File
@@ -3,21 +3,11 @@ import { Types } from "mongoose";
import { EventService, SecretService } from "../../services"; import { EventService, SecretService } from "../../services";
import { eventPushSecrets } from "../../events"; import { eventPushSecrets } from "../../events";
import { BotService } from "../../services"; import { BotService } from "../../services";
import { import { containsGlobPatterns, isValidScopeV3, repackageSecretToRaw } from "../../helpers/secrets";
containsGlobPatterns,
isValidScopeV3,
repackageSecretToRaw
} from "../../helpers/secrets";
import { encryptSymmetric128BitHexKeyUTF8 } from "../../utils/crypto"; import { encryptSymmetric128BitHexKeyUTF8 } from "../../utils/crypto";
import { getAllImportedSecrets } from "../../services/SecretImportService"; import { getAllImportedSecrets } from "../../services/SecretImportService";
import { import { Folder, IMembership, IServiceTokenData, IServiceTokenDataV3 } from "../../models";
Folder, import { Permission } from "../../models/serviceTokenDataV3";
IServiceTokenData,
IServiceTokenDataV3
} from "../../models";
import {
Permission
} from "../../models/serviceTokenDataV3";
import { getFolderByPath, getFolderWithPathFromId } from "../../services/FolderService"; import { getFolderByPath, getFolderWithPathFromId } from "../../services/FolderService";
import { BadRequestError } from "../../utils/errors"; import { BadRequestError } from "../../utils/errors";
import { validateRequest } from "../../helpers/validation"; import { validateRequest } from "../../helpers/validation";
@@ -36,6 +26,12 @@ import { PERMISSION_READ_SECRETS, PERMISSION_WRITE_SECRETS } from "../../variabl
import { ActorType } from "../../ee/models"; import { ActorType } from "../../ee/models";
import { UnauthorizedRequestError } from "../../utils/errors"; import { UnauthorizedRequestError } from "../../utils/errors";
import { AuthData } from "../../interfaces/middleware"; import { AuthData } from "../../interfaces/middleware";
import {
generateSecretApprovalRequest,
getSecretPolicyOfBoard
} from "../../ee/services/SecretApprovalService";
import { CommitType } from "../../ee/models/secretApprovalRequest";
import { IRole } from "../../ee/models/role";
const checkSecretsPermission = async ({ const checkSecretsPermission = async ({
authData, authData,
@@ -49,8 +45,10 @@ const checkSecretsPermission = async ({
environment: string; environment: string;
secretPath: string; secretPath: string;
secretAction: ProjectPermissionActions; // CRUD secretAction: ProjectPermissionActions; // CRUD
}): Promise<(env: string, secPath: string) => boolean> => { }): Promise<{
authVerifier: (env: string, secPath: string) => boolean;
membership?: Omit<IMembership, "customRole"> & { customRole: IRole };
}> => {
let STV2RequiredPermissions = []; let STV2RequiredPermissions = [];
let STV3RequiredPermissions: Permission[] = []; let STV3RequiredPermissions: Permission[] = [];
@@ -75,19 +73,25 @@ const checkSecretsPermission = async ({
switch (authData.actor.type) { switch (authData.actor.type) {
case ActorType.USER: { 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( ForbiddenError.from(permission).throwUnlessCan(
secretAction, secretAction,
subject(ProjectPermissionSub.Secrets, { environment, secretPath }) subject(ProjectPermissionSub.Secrets, { environment, secretPath })
); );
return (env: string, secPath: string) => return {
authVerifier: (env: string, secPath: string) =>
permission.can( permission.can(
secretAction, secretAction,
subject(ProjectPermissionSub.Secrets, { subject(ProjectPermissionSub.Secrets, {
environment: env, environment: env,
secretPath: secPath secretPath: secPath
}) })
); ),
membership
};
} }
case ActorType.SERVICE: { case ActorType.SERVICE: {
await validateServiceTokenDataClientForWorkspace({ await validateServiceTokenDataClientForWorkspace({
@@ -97,7 +101,7 @@ const checkSecretsPermission = async ({
secretPath, secretPath,
requiredPermissions: STV2RequiredPermissions requiredPermissions: STV2RequiredPermissions
}); });
return () => true; return { authVerifier: () => true };
} }
case ActorType.SERVICE_V3: { case ActorType.SERVICE_V3: {
await validateServiceTokenDataV3ClientForWorkspace({ await validateServiceTokenDataV3ClientForWorkspace({
@@ -108,19 +112,21 @@ const checkSecretsPermission = async ({
secretPath, secretPath,
requiredPermissions: STV3RequiredPermissions requiredPermissions: STV3RequiredPermissions
}); });
return (env: string, secPath: string) => return {
authVerifier: (env: string, secPath: string) =>
isValidScopeV3({ isValidScopeV3({
authPayload: authData.authPayload as IServiceTokenDataV3, authPayload: authData.authPayload as IServiceTokenDataV3,
environment: env, environment: env,
secretPath: secPath, secretPath: secPath,
requiredPermissions: STV3RequiredPermissions requiredPermissions: STV3RequiredPermissions
}); })
};
} }
default: { default: {
throw UnauthorizedRequestError(); throw UnauthorizedRequestError();
} }
} }
} };
/** /**
* Return secrets for workspace with id [workspaceId] and environment * Return secrets for workspace with id [workspaceId] and environment
@@ -160,7 +166,7 @@ export const getSecretsRaw = async (req: Request, res: Response) => {
if (!environment || !workspaceId) if (!environment || !workspaceId)
throw BadRequestError({ message: "Missing environment or workspace id" }); throw BadRequestError({ message: "Missing environment or workspace id" });
const permissionCheckFn = await checkSecretsPermission({ const { authVerifier: permissionCheckFn } = await checkSecretsPermission({
authData: req.authData, authData: req.authData,
workspaceId, workspaceId,
environment, environment,
@@ -475,7 +481,7 @@ export const getSecrets = async (req: Request, res: Response) => {
secretPath = getFolderWithPathFromId(folder.nodes, folderId).folderPath; secretPath = getFolderWithPathFromId(folder.nodes, folderId).folderPath;
} }
const permissionCheckFn = await checkSecretsPermission({ const { authVerifier: permissionCheckFn } = await checkSecretsPermission({
authData: req.authData, authData: req.authData,
workspaceId, workspaceId,
environment, environment,
@@ -580,7 +586,7 @@ export const createSecret = async (req: Request, res: Response) => {
params: { secretName } params: { secretName }
} = await validateRequest(reqValidator.CreateSecretV3, req); } = await validateRequest(reqValidator.CreateSecretV3, req);
await checkSecretsPermission({ const { membership } = await checkSecretsPermission({
authData: req.authData, authData: req.authData,
workspaceId, workspaceId,
environment, environment,
@@ -588,6 +594,38 @@ export const createSecret = async (req: Request, res: Response) => {
secretAction: ProjectPermissionActions.Create secretAction: ProjectPermissionActions.Create
}); });
if (membership && type !== "personal") {
const secretApprovalPolicy = await getSecretPolicyOfBoard(workspaceId, environment, secretPath);
if (secretApprovalPolicy) {
const secretApprovalRequest = await generateSecretApprovalRequest({
workspaceId,
environment,
secretPath,
policy: secretApprovalPolicy,
commiterMembershipId: membership._id.toString(),
authData: req.authData,
data: {
[CommitType.CREATE]: [
{
secretName,
secretValueCiphertext,
secretValueIV,
secretValueTag,
secretCommentIV,
secretCommentTag,
secretCommentCiphertext,
skipMultilineEncoding,
secretKeyTag,
secretKeyCiphertext,
secretKeyIV
}
]
}
});
return res.send({ approval: secretApprovalRequest });
}
}
const secret = await SecretService.createSecret({ const secret = await SecretService.createSecret({
secretName, secretName,
workspaceId: new Types.ObjectId(workspaceId), workspaceId: new Types.ObjectId(workspaceId),
@@ -656,7 +694,7 @@ export const updateSecretByName = async (req: Request, res: Response) => {
throw BadRequestError({ message: "Missing encrypted key" }); throw BadRequestError({ message: "Missing encrypted key" });
} }
await checkSecretsPermission({ const { membership } = await checkSecretsPermission({
authData: req.authData, authData: req.authData,
workspaceId, workspaceId,
environment, environment,
@@ -664,6 +702,40 @@ export const updateSecretByName = async (req: Request, res: Response) => {
secretAction: ProjectPermissionActions.Edit secretAction: ProjectPermissionActions.Edit
}); });
if (membership && type !== "personal") {
const secretApprovalPolicy = await getSecretPolicyOfBoard(workspaceId, environment, secretPath);
if (secretApprovalPolicy) {
const secretApprovalRequest = await generateSecretApprovalRequest({
workspaceId,
environment,
secretPath,
policy: secretApprovalPolicy,
commiterMembershipId: membership._id.toString(),
authData: req.authData,
data: {
[CommitType.UPDATE]: [
{
secretName,
newSecretName,
secretValueCiphertext,
secretValueIV,
secretValueTag,
tags,
secretCommentIV,
secretCommentTag,
secretCommentCiphertext,
skipMultilineEncoding,
secretKeyTag,
secretKeyCiphertext,
secretKeyIV
}
]
}
});
return res.send({ approval: secretApprovalRequest });
}
}
const secret = await SecretService.updateSecret({ const secret = await SecretService.updateSecret({
secretName, secretName,
workspaceId: new Types.ObjectId(workspaceId), workspaceId: new Types.ObjectId(workspaceId),
@@ -709,7 +781,7 @@ export const deleteSecretByName = async (req: Request, res: Response) => {
params: { secretName } params: { secretName }
} = await validateRequest(reqValidator.DeleteSecretByNameV3, req); } = await validateRequest(reqValidator.DeleteSecretByNameV3, req);
await checkSecretsPermission({ const { membership } = await checkSecretsPermission({
authData: req.authData, authData: req.authData,
workspaceId, workspaceId,
environment, environment,
@@ -717,6 +789,28 @@ export const deleteSecretByName = async (req: Request, res: Response) => {
secretAction: ProjectPermissionActions.Delete secretAction: ProjectPermissionActions.Delete
}); });
if (membership && type !== "personal") {
const secretApprovalPolicy = await getSecretPolicyOfBoard(workspaceId, environment, secretPath);
if (secretApprovalPolicy) {
const secretApprovalRequest = await generateSecretApprovalRequest({
workspaceId,
environment,
secretPath,
authData: req.authData,
policy: secretApprovalPolicy,
commiterMembershipId: membership._id.toString(),
data: {
[CommitType.DELETE]: [
{
secretName
}
]
}
});
return res.send({ approval: secretApprovalRequest });
}
}
const { secret } = await SecretService.deleteSecret({ const { secret } = await SecretService.deleteSecret({
secretName, secretName,
workspaceId: new Types.ObjectId(workspaceId), workspaceId: new Types.ObjectId(workspaceId),
@@ -744,7 +838,7 @@ export const createSecretByNameBatch = async (req: Request, res: Response) => {
body: { secrets, secretPath, environment, workspaceId } body: { secrets, secretPath, environment, workspaceId }
} = await validateRequest(reqValidator.CreateSecretByNameBatchV3, req); } = await validateRequest(reqValidator.CreateSecretByNameBatchV3, req);
await checkSecretsPermission({ const { membership } = await checkSecretsPermission({
authData: req.authData, authData: req.authData,
workspaceId, workspaceId,
environment, environment,
@@ -752,6 +846,24 @@ export const createSecretByNameBatch = async (req: Request, res: Response) => {
secretAction: ProjectPermissionActions.Create secretAction: ProjectPermissionActions.Create
}); });
if (membership) {
const secretApprovalPolicy = await getSecretPolicyOfBoard(workspaceId, environment, secretPath);
if (secretApprovalPolicy) {
const secretApprovalRequest = await generateSecretApprovalRequest({
workspaceId,
environment,
secretPath,
authData: req.authData,
policy: secretApprovalPolicy,
commiterMembershipId: membership._id.toString(),
data: {
[CommitType.CREATE]: secrets.filter(({ type }) => type === "shared")
}
});
return res.send({ approval: secretApprovalRequest });
}
}
const createdSecrets = await SecretService.createSecretBatch({ const createdSecrets = await SecretService.createSecretBatch({
secretPath, secretPath,
environment, environment,
@@ -770,7 +882,7 @@ export const updateSecretByNameBatch = async (req: Request, res: Response) => {
body: { secrets, secretPath, environment, workspaceId } body: { secrets, secretPath, environment, workspaceId }
} = await validateRequest(reqValidator.UpdateSecretByNameBatchV3, req); } = await validateRequest(reqValidator.UpdateSecretByNameBatchV3, req);
await checkSecretsPermission({ const { membership } = await checkSecretsPermission({
authData: req.authData, authData: req.authData,
workspaceId, workspaceId,
environment, environment,
@@ -778,6 +890,24 @@ export const updateSecretByNameBatch = async (req: Request, res: Response) => {
secretAction: ProjectPermissionActions.Edit secretAction: ProjectPermissionActions.Edit
}); });
if (membership) {
const secretApprovalPolicy = await getSecretPolicyOfBoard(workspaceId, environment, secretPath);
if (secretApprovalPolicy) {
const secretApprovalRequest = await generateSecretApprovalRequest({
workspaceId,
environment,
secretPath,
policy: secretApprovalPolicy,
commiterMembershipId: membership._id.toString(),
data: {
[CommitType.UPDATE]: secrets.filter(({ type }) => type === "shared")
},
authData: req.authData
});
return res.send({ approval: secretApprovalRequest });
}
}
const updatedSecrets = await SecretService.updateSecretBatch({ const updatedSecrets = await SecretService.updateSecretBatch({
secretPath, secretPath,
environment, environment,
@@ -796,7 +926,7 @@ export const deleteSecretByNameBatch = async (req: Request, res: Response) => {
body: { secrets, secretPath, environment, workspaceId } body: { secrets, secretPath, environment, workspaceId }
} = await validateRequest(reqValidator.DeleteSecretByNameBatchV3, req); } = await validateRequest(reqValidator.DeleteSecretByNameBatchV3, req);
await checkSecretsPermission({ const { membership } = await checkSecretsPermission({
authData: req.authData, authData: req.authData,
workspaceId, workspaceId,
environment, environment,
@@ -804,6 +934,24 @@ export const deleteSecretByNameBatch = async (req: Request, res: Response) => {
secretAction: ProjectPermissionActions.Delete secretAction: ProjectPermissionActions.Delete
}); });
if (membership) {
const secretApprovalPolicy = await getSecretPolicyOfBoard(workspaceId, environment, secretPath);
if (secretApprovalPolicy) {
const secretApprovalRequest = await generateSecretApprovalRequest({
workspaceId,
environment,
secretPath,
policy: secretApprovalPolicy,
commiterMembershipId: membership._id.toString(),
data: {
[CommitType.DELETE]: secrets.filter(({ type }) => type === "shared")
},
authData: req.authData
});
return res.send({ approval: secretApprovalRequest });
}
}
const deletedSecrets = await SecretService.deleteSecretBatch({ const deletedSecrets = await SecretService.deleteSecretBatch({
secretPath, secretPath,
environment, environment,
+5 -1
View File
@@ -8,6 +8,8 @@ import * as actionController from "./actionController";
import * as membershipController from "./membershipController"; import * as membershipController from "./membershipController";
import * as cloudProductsController from "./cloudProductsController"; import * as cloudProductsController from "./cloudProductsController";
import * as roleController from "./roleController"; import * as roleController from "./roleController";
import * as secretApprovalPolicyController from "./secretApprovalPolicyController";
import * as secretApprovalRequestController from "./secretApprovalRequestsController";
export { export {
secretController, secretController,
@@ -19,5 +21,7 @@ export {
actionController, actionController,
membershipController, membershipController,
cloudProductsController, cloudProductsController,
roleController roleController,
secretApprovalPolicyController,
secretApprovalRequestController
}; };
@@ -1,20 +1,22 @@
import { ForbiddenError } from "@casl/ability"; import { ForbiddenError, subject } from "@casl/ability";
import { Request, Response } from "express"; import { Request, Response } from "express";
import { nanoid } from "nanoid";
import { import {
ProjectPermissionActions, ProjectPermissionActions,
ProjectPermissionSub, ProjectPermissionSub,
getUserProjectPermissions getUserProjectPermissions
} from "../../ee/services/ProjectRoleService"; } from "../../services/ProjectRoleService";
import { validateRequest } from "../../helpers/validation"; import { validateRequest } from "../../../helpers/validation";
import { SecretApprovalPolicy } from "../../models/secretApprovalPolicy"; import { SecretApprovalPolicy } from "../../models/secretApprovalPolicy";
import { BadRequestError } from "../../utils/errors"; import { getSecretPolicyOfBoard } from "../../services/SecretApprovalService";
import { BadRequestError } from "../../../utils/errors";
import * as reqValidator from "../../validation/secretApproval"; import * as reqValidator from "../../validation/secretApproval";
const ERR_SECRET_APPROVAL_NOT_FOUND = BadRequestError({ message: "secret approval not found" }); const ERR_SECRET_APPROVAL_NOT_FOUND = BadRequestError({ message: "secret approval not found" });
export const createSecretApprovalPolicy = async (req: Request, res: Response) => { export const createSecretApprovalPolicy = async (req: Request, res: Response) => {
const { const {
body: { approvals, secretPath, approvers, environment, workspaceId } body: { approvals, secretPath, approvers, environment, workspaceId, name }
} = await validateRequest(reqValidator.CreateSecretApprovalRule, req); } = await validateRequest(reqValidator.CreateSecretApprovalRule, req);
const { permission } = await getUserProjectPermissions(req.user._id, workspaceId); const { permission } = await getUserProjectPermissions(req.user._id, workspaceId);
@@ -25,6 +27,7 @@ export const createSecretApprovalPolicy = async (req: Request, res: Response) =>
const secretApproval = new SecretApprovalPolicy({ const secretApproval = new SecretApprovalPolicy({
workspace: workspaceId, workspace: workspaceId,
name: name ?? `${environment}-${nanoid(3)}`,
secretPath, secretPath,
environment, environment,
approvals, approvals,
@@ -39,7 +42,7 @@ export const createSecretApprovalPolicy = async (req: Request, res: Response) =>
export const updateSecretApprovalPolicy = async (req: Request, res: Response) => { export const updateSecretApprovalPolicy = async (req: Request, res: Response) => {
const { const {
body: { approvals, approvers, secretPath }, body: { approvals, approvers, secretPath, name },
params: { id } params: { id }
} = await validateRequest(reqValidator.UpdateSecretApprovalRule, req); } = await validateRequest(reqValidator.UpdateSecretApprovalRule, req);
@@ -58,6 +61,7 @@ export const updateSecretApprovalPolicy = async (req: Request, res: Response) =>
const updatedDoc = await SecretApprovalPolicy.findByIdAndUpdate(id, { const updatedDoc = await SecretApprovalPolicy.findByIdAndUpdate(id, {
approvals, approvals,
approvers, approvers,
name: (name || secretApproval?.name) ?? `${secretApproval.environment}-${nanoid(3)}`,
...(secretPath === null ? { $unset: { secretPath: 1 } } : { secretPath }) ...(secretPath === null ? { $unset: { secretPath: 1 } } : { secretPath })
}); });
@@ -107,3 +111,18 @@ export const getSecretApprovalPolicy = async (req: Request, res: Response) => {
approvals: doc 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 });
};
@@ -0,0 +1,333 @@
import { Request, Response } from "express";
import { getUserProjectPermissions } from "../../services/ProjectRoleService";
import { validateRequest } from "../../../helpers/validation";
import { Folder } from "../../../models";
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, SecretApprovalPolicy } from "../../models/secretApprovalPolicy";
import { performSecretApprovalRequestMerge } from "../../services/SecretApprovalService";
import { Types } from "mongoose";
import { EEAuditLogService } from "../../services";
import { EventType } from "../../models";
export const getSecretApprovalRequestCount = async (req: Request, res: Response) => {
const {
query: { workspaceId }
} = await validateRequest(reqValidator.getSecretApprovalRequestCount, req);
const { membership } = await getUserProjectPermissions(req.user._id, workspaceId);
const approvalRequestCount = await SecretApprovalRequest.aggregate([
{
$match: {
workspace: new Types.ObjectId(workspaceId)
}
},
{
$lookup: {
from: SecretApprovalPolicy.collection.name,
localField: "policy",
foreignField: "_id",
as: "policy"
}
},
{ $unwind: "$policy" },
...(membership.role !== "admin"
? [
{
$match: {
$or: [
{ committer: new Types.ObjectId(membership.id) },
{ "policy.approvers": new Types.ObjectId(membership.id) }
]
}
}
]
: []),
{
$group: {
_id: "$status",
count: { $sum: 1 }
}
}
]);
const openRequests = approvalRequestCount.find(({ _id }) => _id === "open");
const closedRequests = approvalRequestCount.find(({ _id }) => _id === "close");
return res.send({
approvals: { open: openRequests?.count || 0, closed: closedRequests?.count || 0 }
});
};
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: new Types.ObjectId(workspaceId),
environment,
committer: committer ? new Types.ObjectId(committer) : undefined,
status
};
// 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.aggregate([
{
$match: query
},
{ $sort: { createdAt: -1 } },
{
$lookup: {
from: SecretApprovalPolicy.collection.name,
localField: "policy",
foreignField: "_id",
as: "policy"
}
},
{ $unwind: "$policy" },
...(membership.role !== "admin"
? [
{
$match: {
$or: [
{ committer: new Types.ObjectId(membership.id) },
{ "policy.approvers": new Types.ObjectId(membership.id) }
]
}
}
]
: []),
{ $skip: offset },
{ $limit: limit }
]);
if (!approvalRequests.length) return res.send({ approvals: [] });
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: ISecretApprovalPolicy }>("policy")
.populate({
path: "commits.secretVersion",
populate: {
path: "tags"
}
})
.populate("commits.secret", "version")
.populate("commits.newVersion.tags")
.lean();
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.policy.approvers.find(
(approverId) => approverId.toString() === membership._id.toString()
)
) {
throw UnauthorizedRequestError({ message: "User has no access" });
}
let secretPath = "/";
const approvalRootFolders = await Folder.findOne({
workspace: secretApprovalRequest.workspace,
environment: secretApprovalRequest.environment
}).lean();
if (approvalRootFolders) {
secretPath =
getFolderWithPathFromId(approvalRootFolders?.nodes, secretApprovalRequest.folderId)
?.folderPath || "/";
}
return res.send({
approval: { ...secretApprovalRequest, secretPath }
});
};
export const updateSecretApprovalReviewStatus = async (req: Request, res: Response) => {
const {
body: { status },
params: { id }
} = await validateRequest(reqValidator.updateSecretApprovalReviewStatus, 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" });
}
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 });
};
export const mergeSecretApprovalRequest = async (req: Request, res: Response) => {
const {
params: { 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.equals(membership.id))
) {
throw UnauthorizedRequestError({ message: "User has no access" });
}
const reviewers = secretApprovalRequest.reviewers.reduce<Record<string, ApprovalStatus>>(
(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,
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 }
);
if (status === "close") {
await EEAuditLogService.createAuditLog(
req.authData,
{
type: EventType.SECRET_APPROVAL_CLOSED,
metadata: {
closedBy: membership._id.toString(),
secretApprovalRequestId: id,
secretApprovalRequestSlug: secretApprovalRequest.slug
}
},
{
workspaceId: secretApprovalRequest.workspace
}
);
} else {
await EEAuditLogService.createAuditLog(
req.authData,
{
type: EventType.SECRET_APPROVAL_REOPENED,
metadata: {
reopenedBy: membership._id.toString(),
secretApprovalRequestId: id,
secretApprovalRequestSlug: secretApprovalRequest.slug
}
},
{
workspaceId: secretApprovalRequest.workspace
}
);
}
return res.send({ approval: updatedRequest });
};
+5 -1
View File
@@ -50,5 +50,9 @@ export enum EventType {
UPDATE_SECRET_IMPORT = "update-secret-import", UPDATE_SECRET_IMPORT = "update-secret-import",
DELETE_SECRET_IMPORT = "delete-secret-import", DELETE_SECRET_IMPORT = "delete-secret-import",
UPDATE_USER_WORKSPACE_ROLE = "update-user-workspace-role", UPDATE_USER_WORKSPACE_ROLE = "update-user-workspace-role",
UPDATE_USER_WORKSPACE_DENIED_PERMISSIONS = "update-user-workspace-denied-permissions" UPDATE_USER_WORKSPACE_DENIED_PERMISSIONS = "update-user-workspace-denied-permissions",
SECRET_APPROVAL_MERGED = "secret-approval-merged",
SECRET_APPROVAL_REQUEST = "secret-approval-request",
SECRET_APPROVAL_CLOSED = "secret-approval-closed",
SECRET_APPROVAL_REOPENED = "secret-approval-reopened"
} }
+40 -1
View File
@@ -437,6 +437,41 @@ interface UpdateUserDeniedPermissions {
}[] }[]
} }
} }
interface SecretApprovalMerge {
type: EventType.SECRET_APPROVAL_MERGED;
metadata: {
mergedBy: string;
secretApprovalRequestSlug: string;
secretApprovalRequestId: string;
};
}
interface SecretApprovalClosed {
type: EventType.SECRET_APPROVAL_CLOSED;
metadata: {
closedBy: string;
secretApprovalRequestSlug: string;
secretApprovalRequestId: string;
};
}
interface SecretApprovalReopened {
type: EventType.SECRET_APPROVAL_REOPENED;
metadata: {
reopenedBy: string;
secretApprovalRequestSlug: string;
secretApprovalRequestId: string;
};
}
interface SecretApprovalRequest {
type: EventType.SECRET_APPROVAL_REQUEST;
metadata: {
committedBy: string;
secretApprovalRequestSlug: string;
secretApprovalRequestId: string;
};
}
export type Event = export type Event =
| GetSecretsEvent | GetSecretsEvent
@@ -476,4 +511,8 @@ export type Event =
| UpdateSecretImportEvent | UpdateSecretImportEvent
| DeleteSecretImportEvent | DeleteSecretImportEvent
| UpdateUserRole | UpdateUserRole
| UpdateUserDeniedPermissions; | UpdateUserDeniedPermissions
| SecretApprovalMerge
| SecretApprovalClosed
| SecretApprovalRequest
| SecretApprovalReopened;
@@ -3,6 +3,7 @@ import { Schema, Types, model } from "mongoose";
export interface ISecretApprovalPolicy { export interface ISecretApprovalPolicy {
_id: Types.ObjectId; _id: Types.ObjectId;
workspace: Types.ObjectId; workspace: Types.ObjectId;
name: string;
environment: string; environment: string;
secretPath?: string; secretPath?: string;
approvers: Types.ObjectId[]; approvers: Types.ObjectId[];
@@ -23,6 +24,9 @@ const secretApprovalPolicySchema = new Schema<ISecretApprovalPolicy>(
ref: "Membership" ref: "Membership"
} }
], ],
name: {
type: String
},
environment: { environment: {
type: String, type: String,
required: true required: true
@@ -0,0 +1,203 @@
import { Schema, Types, model } from "mongoose";
import { customAlphabet } from "nanoid";
import {
ALGORITHM_AES_256_GCM,
ENCODING_SCHEME_BASE64,
ENCODING_SCHEME_UTF8
} from "../../variables";
export enum ApprovalStatus {
PENDING = "pending",
APPROVED = "approved",
REJECTED = "rejected"
}
export enum CommitType {
DELETE = "delete",
UPDATE = "update",
CREATE = "create"
}
const SLUG_ALPHABETS = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
const nanoId = customAlphabet(SLUG_ALPHABETS, 10);
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 type ISecretCommits<T = Types.ObjectId, J = Types.ObjectId> = 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<Omit<ISecretApprovalSecChange, "_id">> & { _id: Types.ObjectId };
op: CommitType.UPDATE;
}
| {
secret: T;
secretVersion: J;
op: CommitType.DELETE;
}
>;
export interface ISecretApprovalRequest {
_id: Types.ObjectId;
committer: Types.ObjectId;
slug: string;
statusChangeBy: Types.ObjectId;
reviewers: {
member: Types.ObjectId;
status: ApprovalStatus;
}[];
workspace: Types.ObjectId;
environment: string;
folderId: string;
hasMerged: boolean;
status: "open" | "close";
policy: Types.ObjectId;
commits: ISecretCommits;
conflicts: Array<{ secretId: string; op: CommitType }>;
}
const secretApprovalSecretChangeSchema = new Schema<ISecretApprovalSecChange>({
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<ISecretApprovalRequest>(
{
workspace: {
type: Schema.Types.ObjectId,
ref: "Workspace",
required: true
},
environment: {
type: String,
required: true
},
folderId: {
type: String,
required: true,
default: "root"
},
slug: {
type: String,
default: () => nanoId()
},
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: ["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" },
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
}
);
export const SecretApprovalRequest = model<ISecretApprovalRequest>(
"SecretApprovalRequest",
secretApprovalRequestSchema
);
+5 -1
View File
@@ -8,6 +8,8 @@ import action from "./action";
import cloudProducts from "./cloudProducts"; import cloudProducts from "./cloudProducts";
import secretScanning from "./secretScanning"; import secretScanning from "./secretScanning";
import roles from "./role"; import roles from "./role";
import secretApprovalPolicy from "./secretApprovalPolicy";
import secretApprovalRequest from "./secretApprovalRequest";
export { export {
secret, secret,
@@ -19,5 +21,7 @@ export {
action, action,
cloudProducts, cloudProducts,
secretScanning, secretScanning,
roles roles,
secretApprovalPolicy,
secretApprovalRequest
}; };
@@ -1,8 +1,8 @@
import express from "express"; import express from "express";
const router = express.Router(); const router = express.Router();
import { requireAuth } from "../../middleware"; import { requireAuth } from "../../../middleware";
import { secretApprovalPolicyController } from "../../controllers/v1"; import { secretApprovalPolicyController } from "../../controllers/v1";
import { AuthMode } from "../../variables"; import { AuthMode } from "../../../variables";
router.get( router.get(
"/", "/",
@@ -12,6 +12,14 @@ router.get(
secretApprovalPolicyController.getSecretApprovalPolicy secretApprovalPolicyController.getSecretApprovalPolicy
); );
router.get(
"/board",
requireAuth({
acceptedAuthModes: [AuthMode.JWT]
}),
secretApprovalPolicyController.getSecretApprovalPolicyOfBoard
);
router.post( router.post(
"/", "/",
requireAuth({ requireAuth({
@@ -0,0 +1,55 @@
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(
"/count",
requireAuth({
acceptedAuthModes: [AuthMode.JWT]
}),
secretApprovalRequestController.getSecretApprovalRequestCount
);
router.get(
"/:id",
requireAuth({
acceptedAuthModes: [AuthMode.JWT]
}),
secretApprovalRequestController.getSecretApprovalRequestDetails
);
router.post(
"/:id/merge",
requireAuth({
acceptedAuthModes: [AuthMode.JWT]
}),
secretApprovalRequestController.mergeSecretApprovalRequest
);
router.post(
"/:id/review",
requireAuth({
acceptedAuthModes: [AuthMode.JWT]
}),
secretApprovalRequestController.updateSecretApprovalReviewStatus
);
router.post(
"/:id/status",
requireAuth({
acceptedAuthModes: [AuthMode.JWT]
}),
secretApprovalRequestController.updateSecretApprovalRequestStatus
);
export default router;
+3 -1
View File
@@ -37,6 +37,7 @@ interface FeatureSet {
status: "incomplete" | "incomplete_expired" | "trialing" | "active" | "past_due" | "canceled" | "unpaid" | null; status: "incomplete" | "incomplete_expired" | "trialing" | "active" | "past_due" | "canceled" | "unpaid" | null;
trial_end: number | null; trial_end: number | null;
has_used_trial: boolean; has_used_trial: boolean;
secretApproval: boolean;
} }
/** /**
@@ -72,7 +73,8 @@ class EELicenseService {
samlSSO: false, samlSSO: false,
status: null, status: null,
trial_end: null, trial_end: null,
has_used_trial: true has_used_trial: true,
secretApproval: false
} }
public localFeatureSet: NodeCache; public localFeatureSet: NodeCache;
@@ -0,0 +1,656 @@
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,
ISecretCommits,
SecretApprovalRequest
} from "../models/secretApprovalRequest";
import { BadRequestError } from "../../utils/errors";
import { getFolderByPath } from "../../services/FolderService";
import { ALGORITHM_AES_256_GCM, ENCODING_SCHEME_UTF8, SECRET_SHARED } from "../../variables";
import TelemetryService from "../../services/TelemetryService";
import { EEAuditLogService, EESecretService } from "../services";
import { EventType, SecretVersion } from "../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) =>
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(b) - getPolicyScore(a)
);
const finalPolicy = policiesByPriority.shift();
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<ISecretApprovalSecChange, "_id" | "version"> & {
secretName: string;
};
type TApprovalUpdateSecret = Partial<Omit<ISecretApprovalSecChange, "_id" | "version">> & {
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;
authData: AuthData;
};
export const generateSecretApprovalRequest = async ({
workspaceId,
environment,
secretPath,
policy,
data,
commiterMembershipId,
authData
}: 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<Record<string, string>>((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<Record<string, string>>((prev, curr, i) => {
prev[updatedSecret[i].secretName] = curr;
return prev;
}, {})
);
// check update secret exists
const oldSecrets = await Secret.find({
workspace: new Types.ObjectId(workspaceId),
folder: folderId,
environment,
type: SECRET_SHARED,
secretBlindIndex: {
$in: updatedSecret.map(({ secretName }) => secretBlindIndexes[secretName])
}
})
.select("+secretBlindIndex")
.lean()
.exec();
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(
nameUpdatedSecrets.map(({ newSecretName }) =>
generateSecretBlindIndexWithSaltHelper({
secretName: newSecretName as string,
salt
})
)
).then((blindIndexes) =>
blindIndexes.reduce<Record<string, string>>((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.length)
throw BadRequestError({ message: "Secret with new name already exist" });
const oldSecretsGroupById = oldSecrets.reduce<Record<string, ISecret>>(
(prev, curr) => ({ ...prev, [curr?.secretBlindIndex || ""]: curr }),
{}
);
const latestSecretVersions = await getLatestSecretVersion(
updatedSecret.map((el) => oldSecretsGroupById[secretBlindIndexes[el.secretName]]._id)
);
commits.push(
...updatedSecret.map((el) => {
const secretId = oldSecretsGroupById[secretBlindIndexes[el.secretName]]._id;
return {
op: CommitType.UPDATE as const,
secret: secretId,
secretVersion: latestSecretVersions[secretId.toString()]._id,
newVersion: {
...el,
secretBlindIndex: newSecretBlindIndexes?.[el.secretName],
_id: new Types.ObjectId(),
version: oldSecretsGroupById[secretBlindIndexes[el.secretName]].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<Record<string, string>>((prev, curr, i) => {
prev[deletedSecrets[i].secretName] = curr;
return prev;
}, {})
);
const secretsToDelete = await Secret.find({
workspace: new Types.ObjectId(workspaceId),
folder: folderId,
environment,
type: SECRET_SHARED,
secretBlindIndex: {
$in: deletedSecrets.map(({ secretName }) => secretBlindIndexes[secretName])
}
})
.select({ secretBlindIndex: 1, _id: 1 })
.lean()
.exec();
if (secretsToDelete.length !== deletedSecrets.length)
throw BadRequestError({ message: "Deleted secrets not found" });
const oldSecretsGroupById = secretsToDelete.reduce<Record<string, ISecret>>(
(prev, curr) => ({ ...prev, [curr?.secretBlindIndex || ""]: curr }),
{}
);
const latestSecretVersions = await getLatestSecretVersion(
deletedSecrets.map((el) => oldSecretsGroupById[secretBlindIndexes[el.secretName]]._id)
);
commits.push(
...deletedSecrets.map((el) => {
const secretId = oldSecretsGroupById[secretBlindIndexes[el.secretName]]._id;
return {
op: CommitType.DELETE as const,
secret: secretId,
secretVersion: latestSecretVersions[secretId.toString()]
};
})
);
}
const secretApprovalRequest = new SecretApprovalRequest({
workspace: workspaceId,
environment,
folderId,
policy,
commits,
committer: commiterMembershipId
});
await secretApprovalRequest.save();
await EEAuditLogService.createAuditLog(
authData,
{
type: EventType.SECRET_APPROVAL_REQUEST,
metadata: {
committedBy: commiterMembershipId,
secretApprovalRequestId: secretApprovalRequest._id.toString(),
secretApprovalRequestSlug: secretApprovalRequest.slug
}
},
{
workspaceId: secretApprovalRequest.workspace
}
);
return secretApprovalRequest;
};
// validation for a merge conditions happen in another function in controller
export const performSecretApprovalRequestMerge = async (
id: string,
authData: AuthData,
userMembershipId: string
) => {
const secretApprovalRequest = await SecretApprovalRequest.findById(id)
.populate<{ commits: ISecretCommits<ISecret> }>({
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<{ secretId: 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<Record<string, boolean>>(
(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, secretId: 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
})
)
});
}
const secretUpdationCommits = secretApprovalRequest.commits.filter(
({ op }) => op === CommitType.UPDATE
) as Array<{
op: CommitType.UPDATE;
newVersion: Partial<Omit<ISecretApprovalSecChange, "_id">> & { _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<Record<string, boolean>>(
(prev, curr) => (curr?.secretBlindIndex ? { ...prev, [curr.secretBlindIndex]: true } : prev),
{}
);
secretUpdationCommits
.filter(
({ newVersion, secret }) =>
(newVersion.secretBlindIndex && conflictGroupByBlindIndex[newVersion.secretBlindIndex]) ||
!secret
)
.forEach((el) => {
conflicts.push({ op: CommitType.UPDATE, secretId: el.newVersion._id.toString() });
});
const nonConflictSecrets = secretUpdationCommits.filter(
({ newVersion, secret }) =>
Boolean(secret) &&
(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
});
})
});
}
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)
});
}
const updatedSecretApproval = await SecretApprovalRequest.findByIdAndUpdate(
id,
{
conflicts,
hasMerged: true,
status: "close",
statusChangeBy: userMembershipId
},
{ 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
});
// question to team where to keep secretKey
await EEAuditLogService.createAuditLog(
authData,
{
type: EventType.SECRET_APPROVAL_MERGED,
metadata: {
mergedBy: userMembershipId,
secretApprovalRequestId: id,
secretApprovalRequestSlug: secretApprovalRequest.slug
}
},
{
workspaceId
}
);
return updatedSecretApproval;
};
@@ -0,0 +1,54 @@
import { z } from "zod";
export const GetSecretApprovalRuleList = z.object({
query: z.object({
workspaceId: z.string().trim()
})
});
export const GetSecretApprovalPolicyOfABoard = z.object({
query: z.object({
workspaceId: z.string().trim(),
environment: z.string().trim(),
secretPath: z.string().trim()
})
});
export const CreateSecretApprovalRule = z.object({
body: z
.object({
workspaceId: z.string(),
name: z.string().optional(),
environment: z.string(),
secretPath: z.string().optional().nullable(),
approvers: z.string().array().min(1),
approvals: z.number().min(1).default(1)
})
.refine((data) => data.approvals <= data.approvers.length, {
path: ["approvals"],
message: "The number of approvals should be lower than the number of approvers."
})
});
export const UpdateSecretApprovalRule = z.object({
params: z.object({
id: z.string()
}),
body: z
.object({
name: z.string().optional(),
approvers: z.string().array().min(1),
approvals: z.number().min(1).default(1),
secretPath: z.string().optional().nullable()
})
.refine((data) => data.approvals <= data.approvers.length, {
path: ["approvals"],
message: "The number of approvals should be lower than the number of approvers."
})
});
export const DeleteSecretApprovalRule = z.object({
params: z.object({
id: z.string()
})
});
@@ -0,0 +1,49 @@
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.enum(["open", "close"]).optional(),
limit: z.coerce.number().default(20),
offset: z.coerce.number().default(0)
})
});
export const getSecretApprovalRequestCount = z.object({
query: z.object({
workspaceId: z.string().trim()
})
});
export const getSecretApprovalRequestDetails = z.object({
params: z.object({
id: z.string().trim()
})
});
export const updateSecretApprovalReviewStatus = z.object({
body: z.object({
status: z.enum([ApprovalStatus.APPROVED, ApprovalStatus.REJECTED])
}),
params: z.object({
id: z.string().trim()
})
});
export const mergeSecretApprovalRequest = 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"])
})
});
+7 -8
View File
@@ -25,11 +25,11 @@ import {
users as eeUsersRouter, users as eeUsersRouter,
workspace as eeWorkspaceRouter, workspace as eeWorkspaceRouter,
roles as v1RoleRouter, roles as v1RoleRouter,
secretApprovalPolicy as v1SecretApprovalPolicy,
secretApprovalRequest as v1SecretApprovalRequest,
secretScanning as v1SecretScanningRouter secretScanning as v1SecretScanningRouter
} from "./ee/routes/v1"; } from "./ee/routes/v1";
import { import { serviceTokenData as v3ServiceTokenDataRouter } from "./ee/routes/v3";
serviceTokenData as v3ServiceTokenDataRouter
} from "./ee/routes/v3";
import { import {
auth as v1AuthRouter, auth as v1AuthRouter,
bot as v1BotRouter, bot as v1BotRouter,
@@ -42,7 +42,6 @@ import {
organization as v1OrganizationRouter, organization as v1OrganizationRouter,
password as v1PasswordRouter, password as v1PasswordRouter,
sso as v1SSORouter, sso as v1SSORouter,
secretApprovalPolicy as v1SecretApprovalPolicy,
secretImps as v1SecretImpsRouter, secretImps as v1SecretImpsRouter,
secret as v1SecretRouter, secret as v1SecretRouter,
secretsFolder as v1SecretsFolder, secretsFolder as v1SecretsFolder,
@@ -183,6 +182,7 @@ const main = async () => {
app.use("/api/v1/roles", v1RoleRouter); app.use("/api/v1/roles", v1RoleRouter);
app.use("/api/v1/secret-approvals", v1SecretApprovalPolicy); app.use("/api/v1/secret-approvals", v1SecretApprovalPolicy);
app.use("/api/v1/sso", v1SSORouter); app.use("/api/v1/sso", v1SSORouter);
app.use("/api/v1/secret-approval-requests", v1SecretApprovalRequest);
// v2 routes (improvements) // v2 routes (improvements)
app.use("/api/v2/signup", v2SignupRouter); app.use("/api/v2/signup", v2SignupRouter);
@@ -228,24 +228,23 @@ const main = async () => {
// await createTestUserForDevelopment(); // await createTestUserForDevelopment();
setUpHealthEndpoint(server); setUpHealthEndpoint(server);
const serverCleanup = async () => { const serverCleanup = async () => {
await DatabaseService.closeDatabase(); await DatabaseService.closeDatabase();
syncSecretsToThirdPartyServices.close(); syncSecretsToThirdPartyServices.close();
githubPushEventSecretScan.close(); githubPushEventSecretScan.close();
process.exit(0); process.exit(0);
} };
process.on("SIGINT", function () { process.on("SIGINT", function () {
server.close(async () => { server.close(async () => {
await serverCleanup() await serverCleanup();
}); });
}); });
process.on("SIGTERM", function () { process.on("SIGTERM", function () {
server.close(async () => { server.close(async () => {
await serverCleanup() await serverCleanup();
}); });
}); });
@@ -1,68 +0,0 @@
import { Schema, Types, model } from "mongoose";
import { ISecretVersion, SecretVersion } from "../ee/models/secretVersion";
enum ApprovalStatus {
PENDING = "pending",
APPROVED = "approved",
REJECTED = "rejected"
}
enum CommitType {
DELETE = "delete",
UPDATE = "update",
CREATE = "create"
}
export interface ISecretApprovalRequest {
_id: Types.ObjectId;
committer: Types.ObjectId;
approvers: {
member: Types.ObjectId;
status: ApprovalStatus;
}[];
approvals: number;
hasMerged: boolean;
status: ApprovalStatus;
commits: {
secretVersion: Types.ObjectId;
newVersion: ISecretVersion;
op: CommitType;
}[];
}
const secretApprovalRequestSchema = new Schema<ISecretApprovalRequest>(
{
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,
required: true
},
hasMerged: { type: Boolean, default: false },
status: { type: String, enum: ApprovalStatus, default: ApprovalStatus.PENDING },
committer: { type: Schema.Types.ObjectId, ref: "Membership" },
commits: [
{
secretVersion: { type: Types.ObjectId, ref: "SecretVersion" },
newVersion: SecretVersion,
op: { type: String, enum: [CommitType], required: true }
}
]
},
{
timestamps: true
}
);
export const SecretApprovalRequest = model<ISecretApprovalRequest>(
"SecretApprovalRequest",
secretApprovalRequestSchema
);
+1 -3
View File
@@ -18,7 +18,6 @@ import integrationAuth from "./integrationAuth";
import secretsFolder from "./secretsFolder"; import secretsFolder from "./secretsFolder";
import webhooks from "./webhook"; import webhooks from "./webhook";
import secretImps from "./secretImps"; import secretImps from "./secretImps";
import secretApprovalPolicy from "./secretApprovalPolicy";
export { export {
signup, signup,
@@ -40,6 +39,5 @@ export {
secretsFolder, secretsFolder,
webhooks, webhooks,
secretImps, secretImps,
sso, sso
secretApprovalPolicy
}; };
-1
View File
@@ -1,4 +1,3 @@
export * from "./secretApproval";
export * from "./user"; export * from "./user";
export * from "./workspace"; export * from "./workspace";
export * from "./bot"; export * from "./bot";
-34
View File
@@ -1,34 +0,0 @@
import { z } from "zod";
export const GetSecretApprovalRuleList = z.object({
query: z.object({
workspaceId: z.string()
})
});
export const CreateSecretApprovalRule = z.object({
body: z.object({
workspaceId: z.string(),
environment: z.string(),
secretPath: z.string().optional().nullable(),
approvers: z.string().array().optional(),
approvals: z.number().min(1).default(1)
})
});
export const UpdateSecretApprovalRule = z.object({
params: z.object({
id: z.string()
}),
body: z.object({
approvers: z.string().array().optional(),
approvals: z.number().min(1).optional(),
secretPath: z.string().optional().nullable()
})
});
export const DeleteSecretApprovalRule = z.object({
params: z.object({
id: z.string()
})
});
File diff suppressed because one or more lines are too long
@@ -1,6 +1,6 @@
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { faAngleRight } from "@fortawesome/free-solid-svg-icons"; import { faAngleRight, faLock } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { useOrganization, useWorkspace } from "@app/context"; import { useOrganization, useWorkspace } from "@app/context";
@@ -16,6 +16,8 @@ type Props = {
onEnvChange?: (slug: string) => void; onEnvChange?: (slug: string) => void;
secretPath?: string; secretPath?: string;
isFolderMode?: boolean; isFolderMode?: boolean;
isProtectedBranch?: boolean;
protectionPolicyName?: string;
}; };
// TODO: make links clickable and clean up // TODO: make links clickable and clean up
@@ -42,7 +44,9 @@ export default function NavHeader({
userAvailableEnvs = [], userAvailableEnvs = [],
onEnvChange, onEnvChange,
isFolderMode, isFolderMode,
secretPath = "/" secretPath = "/",
isProtectedBranch = false,
protectionPolicyName
}: Props): JSX.Element { }: Props): JSX.Element {
const { currentWorkspace } = useWorkspace(); const { currentWorkspace } = useWorkspace();
const { currentOrg } = useOrganization(); const { currentOrg } = useOrganization();
@@ -151,6 +155,11 @@ export default function NavHeader({
</div> </div>
); );
})} })}
{isProtectedBranch && (
<Tooltip content={`Protected by policy ${protectionPolicyName}`}>
<FontAwesomeIcon icon={faLock} className="text-primary ml-2" />
</Tooltip>
)}
</div> </div>
); );
} }
+10 -1
View File
@@ -11,6 +11,9 @@ type Props = {
isLoading?: boolean; 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( const buttonVariants = cva(
[ [
"button", "button",
@@ -67,7 +70,7 @@ const buttonVariants = cva(
{ {
colorSchema: "primary", colorSchema: "primary",
variant: "solid", variant: "solid",
className: "bg-primary-500 bg-opacity-90 hover:bg-primary-500 hover:text-black" className: "text-black bg-primary-500 bg-opacity-90 hover:bg-primary-500 hover:text-black"
}, },
{ {
colorSchema: "primary", colorSchema: "primary",
@@ -106,6 +109,12 @@ const buttonVariants = cva(
variant: "outline", variant: "outline",
className: "text-red hover:bg-red hover:text-black" 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", colorSchema: "primary",
variant: "plain", variant: "plain",
@@ -20,7 +20,8 @@ export enum ProjectPermissionSub {
IpAllowList = "ip-allowlist", IpAllowList = "ip-allowlist",
Workspace = "workspace", Workspace = "workspace",
Secrets = "secrets", Secrets = "secrets",
SecretRollback = "secret-rollback" SecretRollback = "secret-rollback",
SecretApproval = "secret-approval"
} }
type SubjectFields = { type SubjectFields = {
@@ -43,6 +44,7 @@ export type ProjectPermissionSet =
| [ProjectPermissionActions, ProjectPermissionSub.IpAllowList] | [ProjectPermissionActions, ProjectPermissionSub.IpAllowList]
| [ProjectPermissionActions, ProjectPermissionSub.Settings] | [ProjectPermissionActions, ProjectPermissionSub.Settings]
| [ProjectPermissionActions, ProjectPermissionSub.ServiceTokens] | [ProjectPermissionActions, ProjectPermissionSub.ServiceTokens]
| [ProjectPermissionActions, ProjectPermissionSub.SecretApproval]
| [ProjectPermissionActions.Delete, ProjectPermissionSub.Workspace] | [ProjectPermissionActions.Delete, ProjectPermissionSub.Workspace]
| [ProjectPermissionActions.Edit, ProjectPermissionSub.Workspace] | [ProjectPermissionActions.Edit, ProjectPermissionSub.Workspace]
| [ProjectPermissionActions.Read, ProjectPermissionSub.SecretRollback] | [ProjectPermissionActions.Read, ProjectPermissionSub.SecretRollback]
+1
View File
@@ -8,6 +8,7 @@ export * from "./keys";
export * from "./organization"; export * from "./organization";
export * from "./roles"; export * from "./roles";
export * from "./secretApproval"; export * from "./secretApproval";
export * from "./secretApprovalRequest";
export * from "./secretFolders"; export * from "./secretFolders";
export * from "./secretImports"; export * from "./secretImports";
export * from "./secrets"; export * from "./secrets";
@@ -3,4 +3,4 @@ export {
useDeleteSecretApprovalPolicy, useDeleteSecretApprovalPolicy,
useUpdateSecretApprovalPolicy useUpdateSecretApprovalPolicy
} from "./mutation"; } from "./mutation";
export { useGetSecretApprovalPolicies } from "./queries"; export { useGetSecretApprovalPolicies, useGetSecretApprovalPolicyOfABoard } from "./queries";
@@ -9,13 +9,14 @@ export const useCreateSecretApprovalPolicy = () => {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation<{}, {}, TCreateSecretPolicyDTO>({ return useMutation<{}, {}, TCreateSecretPolicyDTO>({
mutationFn: async ({ environment, workspaceId, approvals, approvers, secretPath }) => { mutationFn: async ({ environment, workspaceId, approvals, approvers, secretPath, name }) => {
const { data } = await apiRequest.post("/api/v1/secret-approvals", { const { data } = await apiRequest.post("/api/v1/secret-approvals", {
environment, environment,
workspaceId, workspaceId,
approvals, approvals,
approvers, approvers,
secretPath secretPath,
name
}); });
return data; return data;
}, },
@@ -29,11 +30,12 @@ export const useUpdateSecretApprovalPolicy = () => {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation<{}, {}, TUpdateSecretPolicyDTO>({ return useMutation<{}, {}, TUpdateSecretPolicyDTO>({
mutationFn: async ({ id, approvers, approvals, secretPath }) => { mutationFn: async ({ id, approvers, approvals, secretPath, name }) => {
const { data } = await apiRequest.patch(`/api/v1/secret-approvals/${id}`, { const { data } = await apiRequest.patch(`/api/v1/secret-approvals/${id}`, {
approvals, approvals,
approvers, approvers,
secretPath secretPath,
name
}); });
return data; return data;
}, },
@@ -2,11 +2,19 @@ import { useQuery, UseQueryOptions } from "@tanstack/react-query";
import { apiRequest } from "@app/config/request"; import { apiRequest } from "@app/config/request";
import { TSecretApprovalPolicy } from "./types"; import {
TGetSecretApprovalPoliciesDTO,
TGetSecretApprovalPolicyOfBoardDTO,
TSecretApprovalPolicy
} from "./types";
export const secretApprovalKeys = { export const secretApprovalKeys = {
getApprovalPolicies: (workspaceId: string) => 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) => { const fetchApprovalPolicies = async (workspaceId: string) => {
@@ -20,7 +28,7 @@ const fetchApprovalPolicies = async (workspaceId: string) => {
export const useGetSecretApprovalPolicies = ({ export const useGetSecretApprovalPolicies = ({
workspaceId, workspaceId,
options = {} options = {}
}: { workspaceId: string } & { }: TGetSecretApprovalPoliciesDTO & {
options?: UseQueryOptions< options?: UseQueryOptions<
TSecretApprovalPolicy[], TSecretApprovalPolicy[],
unknown, unknown,
@@ -34,3 +42,35 @@ export const useGetSecretApprovalPolicies = ({
...options, ...options,
enabled: Boolean(workspaceId) && (options?.enabled ?? true) 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<typeof secretApprovalKeys.getApprovalPolicyOfABoard>
>;
}) =>
useQuery({
queryKey: secretApprovalKeys.getApprovalPolicyOfABoard(workspaceId, environment, secretPath),
queryFn: () => fetchApprovalPolicyOfABoard(workspaceId, environment, secretPath),
...options,
enabled: Boolean(workspaceId && secretPath && environment) && (options?.enabled ?? true)
});
@@ -1,14 +1,26 @@
export type TSecretApprovalPolicy = { export type TSecretApprovalPolicy = {
_id: string; _id: string;
workspace: string; workspace: string;
name: string;
environment: string; environment: string;
secretPath?: string; secretPath?: string;
approvers: string[]; approvers: string[];
approvals: number; approvals: number;
}; };
export type TGetSecretApprovalPoliciesDTO = {
workspaceId: string;
};
export type TGetSecretApprovalPolicyOfBoardDTO = {
workspaceId: string;
environment: string;
secretPath: string;
};
export type TCreateSecretPolicyDTO = { export type TCreateSecretPolicyDTO = {
workspaceId: string; workspaceId: string;
name?: string;
environment: string; environment: string;
secretPath?: string | null; secretPath?: string | null;
approvers?: string[]; approvers?: string[];
@@ -17,6 +29,7 @@ export type TCreateSecretPolicyDTO = {
export type TUpdateSecretPolicyDTO = { export type TUpdateSecretPolicyDTO = {
id: string; id: string;
name?: string;
approvers?: string[]; approvers?: string[];
secretPath?: string | null; secretPath?: string | null;
approvals?: number; approvals?: number;
@@ -0,0 +1,10 @@
export {
usePerformSecretApprovalRequestMerge,
useUpdateSecretApprovalRequestStatus,
useUpdateSecretApprovalReviewStatus
} from "./mutation";
export {
useGetSecretApprovalRequestCount,
useGetSecretApprovalRequestDetails,
useGetSecretApprovalRequests
} from "./queries";
@@ -0,0 +1,59 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { apiRequest } from "@app/config/request";
import { secretApprovalRequestKeys } from "./queries";
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`, {
status
});
return data;
},
onSuccess: (_, { id, workspaceId }) => {
queryClient.invalidateQueries(secretApprovalRequestKeys.detail({ id }));
queryClient.invalidateQueries(secretApprovalRequestKeys.count({ workspaceId }));
}
});
};
export const usePerformSecretApprovalRequestMerge = () => {
const queryClient = useQueryClient();
return useMutation<{}, {}, TPerformSecretApprovalRequestMerge>({
mutationFn: async ({ id }) => {
const { data } = await apiRequest.post(`/api/v1/secret-approval-requests/${id}/merge`);
return data;
},
onSuccess: (_, { id, workspaceId }) => {
queryClient.invalidateQueries(secretApprovalRequestKeys.detail({ id }));
queryClient.invalidateQueries(secretApprovalRequestKeys.list({ workspaceId }));
queryClient.invalidateQueries(secretApprovalRequestKeys.count({ workspaceId }));
}
});
};
@@ -0,0 +1,224 @@
import {
useInfiniteQuery,
UseInfiniteQueryOptions,
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 {
TGetSecretApprovalRequestCount,
TGetSecretApprovalRequestDetails,
TGetSecretApprovalRequestList,
TSecretApprovalRequest,
TSecretApprovalRequestCount,
TSecretApprovalSecChange,
TSecretApprovalSecChangeData
} from "./types";
export const secretApprovalRequestKeys = {
list: ({
workspaceId,
environment,
status,
committer,
offset,
limit
}: TGetSecretApprovalRequestList) =>
[
{ workspaceId, environment, status, committer, offset, limit },
"secret-approval-requests"
] as const,
detail: ({ id }: Omit<TGetSecretApprovalRequestDetails, "decryptKey">) =>
[{ id }, "secret-approval-request-detail"] as const,
count: ({ workspaceId }: TGetSecretApprovalRequestCount) => [
{ workspaceId },
"secret-approval-request-count"
]
};
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,
committer,
status = "open",
limit = 20,
offset
}: TGetSecretApprovalRequestList) => {
const { data } = await apiRequest.get<{ approvals: TSecretApprovalRequest[] }>(
"/api/v1/secret-approval-requests",
{
params: {
workspaceId,
environment,
committer,
status,
limit,
offset
}
}
);
return data.approvals;
};
export const useGetSecretApprovalRequests = ({
workspaceId,
environment,
options = {},
status,
limit = 20,
committer
}: TGetSecretApprovalRequestList & {
options?: Omit<
UseInfiniteQueryOptions<
TSecretApprovalRequest[],
unknown,
TSecretApprovalRequest[],
ReturnType<typeof secretApprovalRequestKeys.list>
>,
"queryKey" | "queryFn"
>;
}) =>
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) => {
if (lastPage.length && lastPage.length < limit) return undefined;
return lastPage?.length !== 0 ? pages.length * limit : undefined;
}
});
const fetchSecretApprovalRequestDetails = async ({
id
}: Omit<TGetSecretApprovalRequestDetails, "decryptKey">) => {
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<TSecretApprovalSecChange, DecryptedSecret>,
ReturnType<typeof secretApprovalRequestKeys.detail>
>,
"queryKey" | "queryFn"
>;
}) =>
useQuery({
queryKey: secretApprovalRequestKeys.detail({ id }),
queryFn: () => fetchSecretApprovalRequestDetails({ id }),
select: (data) => ({
...data,
commits: data.commits.map(({ secretVersion, op, newVersion, secret }) => ({
op,
secret,
secretVersion: secretVersion ? decryptSecrets([secretVersion], decryptKey)[0] : undefined,
newVersion: newVersion ? decryptSecretApprovalSecret(newVersion, decryptKey) : undefined
}))
}),
enabled: Boolean(id && decryptKey) && (options?.enabled ?? true)
});
const fetchSecretApprovalRequestCount = async ({ workspaceId }: TGetSecretApprovalRequestCount) => {
const { data } = await apiRequest.get<{ approvals: TSecretApprovalRequestCount }>(
"/api/v1/secret-approval-requests/count",
{ params: { workspaceId } }
);
return data.approvals;
};
export const useGetSecretApprovalRequestCount = ({
workspaceId,
options = {}
}: TGetSecretApprovalRequestCount & {
options?: Omit<
UseQueryOptions<
TSecretApprovalRequestCount,
unknown,
TSecretApprovalRequestCount,
ReturnType<typeof secretApprovalRequestKeys.count>
>,
"queryKey" | "queryFn"
>;
}) =>
useQuery({
queryKey: secretApprovalRequestKeys.count({ workspaceId }),
queryFn: () => fetchSecretApprovalRequestCount({ workspaceId }),
enabled: Boolean(workspaceId) && (options?.enabled ?? true)
});
@@ -0,0 +1,113 @@
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;
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[];
version: number;
};
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;
slug: string;
createdAt: string;
committer: string;
reviewers: {
member: string;
status: ApprovalStatus;
}[];
workspace: string;
environment: string;
folderId: string;
secretPath: string;
hasMerged: boolean;
status: "open" | "close";
policy: TSecretApprovalPolicy;
statusChangeBy: string;
conflicts: Array<{ secretId: string; op: CommitType.UPDATE }>;
commits: {
// if there is no secret means it was creation
secret?: { version: number };
secretVersion: J;
// if there is no new version its for Delete
newVersion?: T;
op: CommitType;
}[];
};
export type TSecretApprovalRequestCount = {
open: number;
closed: number;
};
export type TGetSecretApprovalRequestList = {
workspaceId: string;
environment?: string;
status?: "open" | "close";
committer?: string;
limit?: number;
offset?: number;
};
export type TGetSecretApprovalRequestCount = {
workspaceId: string;
};
export type TGetSecretApprovalRequestDetails = {
id: string;
decryptKey: UserWsKeyPair;
};
export type TUpdateSecretApprovalReviewStatusDTO = {
status: ApprovalStatus;
id: string;
};
export type TUpdateSecretApprovalRequestStatusDTO = {
status: "open" | "close";
id: string;
workspaceId: string;
};
export type TPerformSecretApprovalRequestMerge = {
id: string;
workspaceId: string;
};
@@ -8,6 +8,7 @@ import {
} from "@app/components/utilities/cryptography/crypto"; } from "@app/components/utilities/cryptography/crypto";
import { apiRequest } from "@app/config/request"; import { apiRequest } from "@app/config/request";
import { secretApprovalRequestKeys } from "../secretApprovalRequest/queries";
import { secretSnapshotKeys } from "../secretSnapshots/queries"; import { secretSnapshotKeys } from "../secretSnapshots/queries";
import { secretKeys } from "./queries"; import { secretKeys } from "./queries";
import { import {
@@ -114,6 +115,7 @@ export const useCreateSecretV3 = ({
queryClient.invalidateQueries( queryClient.invalidateQueries(
secretSnapshotKeys.count({ environment, workspaceId, directory: secretPath }) secretSnapshotKeys.count({ environment, workspaceId, directory: secretPath })
); );
queryClient.invalidateQueries(secretApprovalRequestKeys.count({ workspaceId }));
}, },
...options ...options
}); });
@@ -173,6 +175,7 @@ export const useUpdateSecretV3 = ({
queryClient.invalidateQueries( queryClient.invalidateQueries(
secretSnapshotKeys.count({ environment, workspaceId, directory: secretPath }) secretSnapshotKeys.count({ environment, workspaceId, directory: secretPath })
); );
queryClient.invalidateQueries(secretApprovalRequestKeys.count({ workspaceId }));
}, },
...options ...options
}); });
@@ -209,6 +212,7 @@ export const useDeleteSecretV3 = ({
queryClient.invalidateQueries( queryClient.invalidateQueries(
secretSnapshotKeys.count({ environment, workspaceId, directory: secretPath }) secretSnapshotKeys.count({ environment, workspaceId, directory: secretPath })
); );
queryClient.invalidateQueries(secretApprovalRequestKeys.count({ workspaceId }));
}, },
...options ...options
}); });
@@ -261,6 +265,7 @@ export const useCreateSecretBatch = ({
queryClient.invalidateQueries( queryClient.invalidateQueries(
secretSnapshotKeys.count({ environment, workspaceId, directory: secretPath }) secretSnapshotKeys.count({ environment, workspaceId, directory: secretPath })
); );
queryClient.invalidateQueries(secretApprovalRequestKeys.count({ workspaceId }));
}, },
...options ...options
}); });
@@ -313,6 +318,7 @@ export const useUpdateSecretBatch = ({
queryClient.invalidateQueries( queryClient.invalidateQueries(
secretSnapshotKeys.count({ environment, workspaceId, directory: secretPath }) secretSnapshotKeys.count({ environment, workspaceId, directory: secretPath })
); );
queryClient.invalidateQueries(secretApprovalRequestKeys.count({ workspaceId }));
}, },
...options ...options
}); });
@@ -349,6 +355,7 @@ export const useDeleteSecretBatch = ({
queryClient.invalidateQueries( queryClient.invalidateQueries(
secretSnapshotKeys.count({ environment, workspaceId, directory: secretPath }) secretSnapshotKeys.count({ environment, workspaceId, directory: secretPath })
); );
queryClient.invalidateQueries(secretApprovalRequestKeys.count({ workspaceId }));
}, },
...options ...options
}); });
+4 -1
View File
@@ -26,7 +26,10 @@ export const secretKeys = {
getSecretVersion: (secretId: string) => [{ secretId }, "secret-versions"] as const 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 PRIVATE_KEY = localStorage.getItem("PRIVATE_KEY") as string;
const key = decryptAssymmetric({ const key = decryptAssymmetric({
ciphertext: decryptFileKey.encryptedKey, ciphertext: decryptFileKey.encryptedKey,
+10 -1
View File
@@ -11,12 +11,21 @@ export type SubscriptionPlan = {
rbac: boolean; rbac: boolean;
secretVersioning: boolean; secretVersioning: boolean;
slug: string; slug: string;
secretApproval: string;
tier: number; tier: number;
workspaceLimit: number; workspaceLimit: number;
workspacesUsed: number; workspacesUsed: number;
environmentLimit: number; environmentLimit: number;
samlSSO: boolean; samlSSO: boolean;
status: "incomplete" | "incomplete_expired" | "trialing" | "active" | "past_due" | "canceled" | "unpaid" | null; status:
| "incomplete"
| "incomplete_expired"
| "trialing"
| "active"
| "past_due"
| "canceled"
| "unpaid"
| null;
trial_end: number | null; trial_end: number | null;
has_used_trial: boolean; has_used_trial: boolean;
}; };
+7 -1
View File
@@ -5,13 +5,19 @@ export type { TCloudIntegration, TIntegration } from "./integrations/types";
export type { UserWsKeyPair } from "./keys/types"; export type { UserWsKeyPair } from "./keys/types";
export type { Organization } from "./organization/types"; export type { Organization } from "./organization/types";
export type { TSecretApprovalPolicy } from "./secretApproval/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 { TSecretFolder } from "./secretFolders/types";
export type { TImportedSecrets, TSecretImports } from "./secretImports/types"; export type { TImportedSecrets, TSecretImports } from "./secretImports/types";
export * from "./secrets/types"; export * from "./secrets/types";
export type { CreateServiceTokenDTO, ServiceToken } from "./serviceTokens/types"; export type { CreateServiceTokenDTO, ServiceToken } from "./serviceTokens/types";
export type { SubscriptionPlan } from "./subscriptions/types"; export type { SubscriptionPlan } from "./subscriptions/types";
export type { WsTag } from "./tags/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 { TWebhook } from "./webhooks/types";
export type { export type {
CreateEnvironmentDTO, CreateEnvironmentDTO,
+14 -8
View File
@@ -66,6 +66,7 @@ import {
useAddUserToWs, useAddUserToWs,
useCreateWorkspace, useCreateWorkspace,
useGetOrgTrialUrl, useGetOrgTrialUrl,
useGetSecretApprovalRequestCount,
useLogoutUser, useLogoutUser,
useUploadWsKey useUploadWsKey
} from "@app/hooks/api"; } from "@app/hooks/api";
@@ -114,7 +115,9 @@ export const AppLayout = ({ children }: LayoutProps) => {
const { orgs, currentOrg } = useOrganization(); const { orgs, currentOrg } = useOrganization();
const { user } = useUser(); const { user } = useUser();
const { subscription } = useSubscription(); const { subscription } = useSubscription();
const workspaceId = currentWorkspace?._id || "";
// const [ isLearningNoteOpen, setIsLearningNoteOpen ] = useState(true); // const [ isLearningNoteOpen, setIsLearningNoteOpen ] = useState(true);
const { data: secretApprovalReqCount } = useGetSecretApprovalRequestCount({ workspaceId });
const isAddingProjectsAllowed = subscription?.workspaceLimit const isAddingProjectsAllowed = subscription?.workspaceLimit
? subscription.workspacesUsed < subscription.workspaceLimit ? subscription.workspacesUsed < subscription.workspaceLimit
@@ -477,21 +480,24 @@ export const AppLayout = ({ children }: LayoutProps) => {
</MenuItem> </MenuItem>
</a> </a>
</Link> </Link>
{process.env.NEXT_PUBLIC_SECRET_APPROVAL === "true" && (
<Link href={`/project/${currentWorkspace?._id}/approval`} passHref> <Link href={`/project/${currentWorkspace?._id}/approval`} passHref>
<a> <a className="relative">
<MenuItem <MenuItem
isSelected={ isSelected={
router.asPath === `/project/${currentWorkspace?._id}/allowlist` router.asPath === `/project/${currentWorkspace?._id}/approval`
} }
icon="system-outline-126-verified" icon="system-outline-189-domain-verification"
> >
Admin Panel Secret approval
{Boolean(secretApprovalReqCount?.open) && (
<span className="text-xs p-0.5 rounded ml-2 bg-primary text-black">
{secretApprovalReqCount?.open}
</span>
)}
</MenuItem> </MenuItem>
</a> </a>
</Link> </Link>
)} <Link href={`/project/${currentWorkspace?._id}/allowlist`} passHref>
{/* <Link href={`/project/${currentWorkspace?._id}/allowlist`} passHref>
<a> <a>
<MenuItem <MenuItem
isSelected={ isSelected={
@@ -502,7 +508,7 @@ export const AppLayout = ({ children }: LayoutProps) => {
IP Allowlist IP Allowlist
</MenuItem> </MenuItem>
</a> </a>
</Link> */} </Link>
<Link href={`/project/${currentWorkspace?._id}/audit-logs`} passHref> <Link href={`/project/${currentWorkspace?._id}/audit-logs`} passHref>
<a> <a>
<MenuItem <MenuItem
@@ -9,6 +9,7 @@ import {
faLock, faLock,
faNetworkWired, faNetworkWired,
faPuzzlePiece, faPuzzlePiece,
faShield,
faTags, faTags,
faUser, faUser,
faUsers faUsers
@@ -18,7 +19,7 @@ import { zodResolver } from "@hookform/resolvers/zod";
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider"; import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
import { Button, FormControl, Input, UpgradePlanModal } from "@app/components/v2"; import { Button, FormControl, Input, UpgradePlanModal } from "@app/components/v2";
import { useOrganization, useSubscription, useWorkspace } from "@app/context"; import { ProjectPermissionSub, useOrganization, useSubscription, useWorkspace } from "@app/context";
import { usePopUp } from "@app/hooks"; import { usePopUp } from "@app/hooks";
import { useCreateRole, useUpdateRole } from "@app/hooks/api"; import { useCreateRole, useUpdateRole } from "@app/hooks/api";
import { TRole } from "@app/hooks/api/roles/types"; import { TRole } from "@app/hooks/api/roles/types";
@@ -41,6 +42,12 @@ const SINGLE_PERMISSION_LIST = [
icon: faPuzzlePiece, icon: faPuzzlePiece,
formName: "integrations" formName: "integrations"
}, },
{
title: "Secret Protect policy",
subtitle: "Manage policies for secret protection for unauthorized secret changes",
icon: faShield,
formName: ProjectPermissionSub.SecretApproval
},
{ {
title: "Roles", title: "Roles",
subtitle: "Role management control", subtitle: "Role management control",
@@ -1,6 +1,7 @@
/* eslint-disable no-param-reassign */ /* eslint-disable no-param-reassign */
import { z } from "zod"; import { z } from "zod";
import { ProjectPermissionSub } from "@app/context";
import { TProjectPermission } from "@app/hooks/api/roles/types"; import { TProjectPermission } from "@app/hooks/api/roles/types";
const generalPermissionSchema = z const generalPermissionSchema = z
@@ -41,6 +42,8 @@ export const formSchema = z.object({
tags: generalPermissionSchema, tags: generalPermissionSchema,
"audit-logs": generalPermissionSchema, "audit-logs": generalPermissionSchema,
"ip-allowlist": generalPermissionSchema, "ip-allowlist": generalPermissionSchema,
// akhilmhdh: refactor all keys like below
[ProjectPermissionSub.SecretApproval]: generalPermissionSchema,
workspace: z workspace: z
.object({ .object({
edit: z.boolean().optional(), edit: z.boolean().optional(),
@@ -6,6 +6,7 @@ import { motion } from "framer-motion";
import { twMerge } from "tailwind-merge"; import { twMerge } from "tailwind-merge";
import { Checkbox, Select, SelectItem } from "@app/components/v2"; import { Checkbox, Select, SelectItem } from "@app/components/v2";
import { ProjectPermissionSub } from "@app/context";
import { useToggle } from "@app/hooks"; import { useToggle } from "@app/hooks";
import { TFormSchema } from "./ProjectRoleModifySection.utils"; import { TFormSchema } from "./ProjectRoleModifySection.utils";
@@ -21,7 +22,8 @@ type Props = {
| "environments" | "environments"
| "tags" | "tags"
| "audit-logs" | "audit-logs"
| "ip-allowlist"; | "ip-allowlist"
| ProjectPermissionSub.SecretApproval;
isNonEditable?: boolean; isNonEditable?: boolean;
setValue: UseFormSetValue<TFormSchema>; setValue: UseFormSetValue<TFormSchema>;
control: Control<TFormSchema>; control: Control<TFormSchema>;
@@ -2,6 +2,7 @@ import { Tab, TabList, TabPanel, Tabs } from "@app/components/v2";
import { useWorkspace } from "@app/context"; import { useWorkspace } from "@app/context";
import { SecretApprovalPolicyList } from "./components/SecretApprovalPolicyList"; import { SecretApprovalPolicyList } from "./components/SecretApprovalPolicyList";
import { SecretApprovalRequest } from "./components/SecretApprovalRequest";
enum TabSection { enum TabSection {
ApprovalRequests = "approval-requests", ApprovalRequests = "approval-requests",
@@ -13,15 +14,18 @@ export const SecretApprovalPage = () => {
const workspaceId = currentWorkspace?._id || ""; const workspaceId = currentWorkspace?._id || "";
return ( return (
<div className="container mx-auto bg-bunker-800 text-white w-full h-full"> <div className="container mx-auto bg-bunker-800 text-white w-full h-full max-w-7xl">
<div className="my-6"> <div className="my-6">
<p className="text-3xl font-semibold text-gray-200">Admin Panels</p> <p className="text-3xl font-semibold text-gray-200">Secret Approvals</p>
</div> </div>
<Tabs defaultValue={TabSection.ApprovalRequests}> <Tabs defaultValue={TabSection.ApprovalRequests}>
<TabList> <TabList>
<Tab value={TabSection.ApprovalRequests}>Secret PRs</Tab> <Tab value={TabSection.ApprovalRequests}>Secret PRs</Tab>
<Tab value={TabSection.Rules}>Policies</Tab> <Tab value={TabSection.Rules}>Policies</Tab>
</TabList> </TabList>
<TabPanel value={TabSection.ApprovalRequests}>
<SecretApprovalRequest />
</TabPanel>
<TabPanel value={TabSection.Rules}> <TabPanel value={TabSection.Rules}>
<SecretApprovalPolicyList workspaceId={workspaceId} /> <SecretApprovalPolicyList workspaceId={workspaceId} />
</TabPanel> </TabPanel>
@@ -2,6 +2,7 @@ import { faFileShield, faPlus } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider"; import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
import { ProjectPermissionCan } from "@app/components/permissions";
import { import {
Button, Button,
DeleteActionModal, DeleteActionModal,
@@ -13,8 +14,15 @@ import {
Td, Td,
Th, Th,
THead, THead,
Tr Tr,
UpgradePlanModal
} from "@app/components/v2"; } from "@app/components/v2";
import {
ProjectPermissionActions,
ProjectPermissionSub,
useProjectPermission,
useSubscription
} from "@app/context";
import { usePopUp } from "@app/hooks"; import { usePopUp } from "@app/hooks";
import { import {
useDeleteSecretApprovalPolicy, useDeleteSecretApprovalPolicy,
@@ -33,13 +41,19 @@ type Props = {
export const SecretApprovalPolicyList = ({ workspaceId }: Props) => { export const SecretApprovalPolicyList = ({ workspaceId }: Props) => {
const { handlePopUpToggle, handlePopUpOpen, handlePopUpClose, popUp } = usePopUp([ const { handlePopUpToggle, handlePopUpOpen, handlePopUpClose, popUp } = usePopUp([
"secretPolicyForm", "secretPolicyForm",
"deletePolicy" "deletePolicy",
"upgradePlan"
] as const); ] as const);
const permission = useProjectPermission();
const { subscription } = useSubscription();
const { createNotification } = useNotificationContext(); const { createNotification } = useNotificationContext();
const { data: members } = useGetWorkspaceUsers(workspaceId); const { data: members } = useGetWorkspaceUsers(workspaceId);
const { data: policies, isLoading: isPoliciesLoading } = useGetSecretApprovalPolicies({ const { data: policies, isLoading: isPoliciesLoading } = useGetSecretApprovalPolicies({
workspaceId workspaceId,
options: {
enabled: permission.can(ProjectPermissionActions.Read, ProjectPermissionSub.SecretApproval)
}
}); });
const { mutateAsync: deleteSecretApprovalPolicy } = useDeleteSecretApprovalPolicy(); const { mutateAsync: deleteSecretApprovalPolicy } = useDeleteSecretApprovalPolicy();
@@ -75,18 +89,33 @@ export const SecretApprovalPolicyList = ({ workspaceId }: Props) => {
</div> </div>
</div> </div>
<div> <div>
<ProjectPermissionCan
I={ProjectPermissionActions.Create}
a={ProjectPermissionSub.SecretApproval}
>
{(isAllowed) => (
<Button <Button
onClick={() => handlePopUpOpen("secretPolicyForm")} onClick={() => {
if (subscription && !subscription?.secretApproval) {
handlePopUpOpen("upgradePlan");
return;
}
handlePopUpOpen("secretPolicyForm");
}}
leftIcon={<FontAwesomeIcon icon={faPlus} />} leftIcon={<FontAwesomeIcon icon={faPlus} />}
isDisabled={!isAllowed}
> >
Create policy Create policy
</Button> </Button>
)}
</ProjectPermissionCan>
</div> </div>
</div> </div>
<TableContainer> <TableContainer>
<Table> <Table>
<THead> <THead>
<Tr> <Tr>
<Th>Name</Th>
<Th>Environment</Th> <Th>Environment</Th>
<Th>Secret Path</Th> <Th>Secret Path</Th>
<Th>Eligible Approvers</Th> <Th>Eligible Approvers</Th>
@@ -99,9 +128,11 @@ export const SecretApprovalPolicyList = ({ workspaceId }: Props) => {
<TableSkeleton columns={4} innerKey="secret-policies" className="bg-mineshaft-700" /> <TableSkeleton columns={4} innerKey="secret-policies" className="bg-mineshaft-700" />
)} )}
{!isPoliciesLoading && !policies?.length && ( {!isPoliciesLoading && !policies?.length && (
<Tr>
<Td colSpan={5}> <Td colSpan={5}>
<EmptyState title="No policies found" icon={faFileShield} /> <EmptyState title="No policies found" icon={faFileShield} />
</Td> </Td>
</Tr>
)} )}
{policies?.map((policy) => ( {policies?.map((policy) => (
<SecretApprovalPolicyRow <SecretApprovalPolicyRow
@@ -130,6 +161,11 @@ export const SecretApprovalPolicyList = ({ workspaceId }: Props) => {
onChange={(isOpen) => handlePopUpToggle("deletePolicy", isOpen)} onChange={(isOpen) => handlePopUpToggle("deletePolicy", isOpen)}
onDeleteApproved={handleDeletePolicy} onDeleteApproved={handleDeletePolicy}
/> />
<UpgradePlanModal
isOpen={popUp.upgradePlan.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("upgradePlan", isOpen)}
text="You can add secret approval policy if you switch to Infisical's Team plan."
/>
</div> </div>
); );
}; };
@@ -2,6 +2,7 @@ import { useState } from "react";
import { faCheckCircle, faPencil, faTrash } from "@fortawesome/free-solid-svg-icons"; import { faCheckCircle, faPencil, faTrash } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { ProjectPermissionCan } from "@app/components/permissions";
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
@@ -11,9 +12,9 @@ import {
IconButton, IconButton,
Input, Input,
Td, Td,
Tooltip,
Tr Tr
} from "@app/components/v2"; } from "@app/components/v2";
import { ProjectPermissionActions, ProjectPermissionSub, useProjectPermission } from "@app/context";
import { useUpdateSecretApprovalPolicy } from "@app/hooks/api"; import { useUpdateSecretApprovalPolicy } from "@app/hooks/api";
import { TSecretApprovalPolicy } from "@app/hooks/api/types"; import { TSecretApprovalPolicy } from "@app/hooks/api/types";
import { TWorkspaceUser } from "@app/hooks/api/users/types"; import { TWorkspaceUser } from "@app/hooks/api/users/types";
@@ -35,9 +36,11 @@ export const SecretApprovalPolicyRow = ({
}: Props) => { }: Props) => {
const [selectedApprovers, setSelectedApprovers] = useState<string[]>([]); const [selectedApprovers, setSelectedApprovers] = useState<string[]>([]);
const { mutate: updateSecretApprovalPolicy, isLoading } = useUpdateSecretApprovalPolicy(); const { mutate: updateSecretApprovalPolicy, isLoading } = useUpdateSecretApprovalPolicy();
const permission = useProjectPermission();
return ( return (
<Tr> <Tr>
<Td>{policy.name}</Td>
<Td>{policy.environment}</Td> <Td>{policy.environment}</Td>
<Td>{policy.secretPath || "*"}</Td> <Td>{policy.secretPath || "*"}</Td>
<Td> <Td>
@@ -61,7 +64,13 @@ export const SecretApprovalPolicyRow = ({
} }
}} }}
> >
<DropdownMenuTrigger asChild disabled={isLoading}> <DropdownMenuTrigger
asChild
disabled={
isLoading ||
permission.cannot(ProjectPermissionActions.Edit, ProjectPermissionSub.SecretApproval)
}
>
<Input <Input
isReadOnly isReadOnly
value={policy.approvers?.length ? `${policy.approvers.length} selected` : "None"} value={policy.approvers?.length ? `${policy.approvers.length} selected` : "None"}
@@ -72,7 +81,7 @@ export const SecretApprovalPolicyRow = ({
style={{ width: "var(--radix-dropdown-menu-trigger-width)" }} style={{ width: "var(--radix-dropdown-menu-trigger-width)" }}
align="start" align="start"
> >
<DropdownMenuLabel>Select members that must approve changes</DropdownMenuLabel> <DropdownMenuLabel>Select members that are allowed to approve changes</DropdownMenuLabel>
{members?.map(({ _id, user }) => { {members?.map(({ _id, user }) => {
const isChecked = selectedApprovers.includes(_id); const isChecked = selectedApprovers.includes(_id);
return ( return (
@@ -97,22 +106,37 @@ export const SecretApprovalPolicyRow = ({
<Td>{policy.approvals}</Td> <Td>{policy.approvals}</Td>
<Td> <Td>
<div className="flex items-center justify-end space-x-4"> <div className="flex items-center justify-end space-x-4">
<Tooltip content="Edit"> <ProjectPermissionCan
<IconButton variant="plain" ariaLabel="edit" onClick={onEdit}> I={ProjectPermissionActions.Edit}
a={ProjectPermissionSub.SecretApproval}
renderTooltip
allowedLabel="Edit"
>
{(isAllowed) => (
<IconButton variant="plain" ariaLabel="edit" onClick={onEdit} isDisabled={!isAllowed}>
<FontAwesomeIcon icon={faPencil} size="lg" /> <FontAwesomeIcon icon={faPencil} size="lg" />
</IconButton> </IconButton>
</Tooltip> )}
<Tooltip content="Delete"> </ProjectPermissionCan>
<ProjectPermissionCan
I={ProjectPermissionActions.Delete}
a={ProjectPermissionSub.SecretApproval}
renderTooltip
allowedLabel="Delete"
>
{(isAllowed) => (
<IconButton <IconButton
variant="plain" variant="plain"
colorSchema="danger" colorSchema="danger"
size="lg" size="lg"
ariaLabel="edit" ariaLabel="edit"
onClick={onDelete} onClick={onDelete}
isDisabled={!isAllowed}
> >
<FontAwesomeIcon icon={faTrash} /> <FontAwesomeIcon icon={faTrash} />
</IconButton> </IconButton>
</Tooltip> )}
</ProjectPermissionCan>
</div> </div>
</Td> </Td>
</Tr> </Tr>
@@ -33,12 +33,18 @@ type Props = {
editValues?: TSecretApprovalPolicy; editValues?: TSecretApprovalPolicy;
}; };
const formSchema = z.object({ const formSchema = z
.object({
environment: z.string(), environment: z.string(),
name: z.string().optional(),
secretPath: z.string().optional().nullable(), secretPath: z.string().optional().nullable(),
approvals: z.number().min(1), approvals: z.number().min(1),
approvers: z.string().array().optional() approvers: z.string().array().min(1)
}); })
.refine((data) => data.approvals <= data.approvers.length, {
path: ["approvals"],
message: "The number of approvals should be lower than the number of approvers."
});
type TFormSchema = z.infer<typeof formSchema>; type TFormSchema = z.infer<typeof formSchema>;
@@ -126,6 +132,15 @@ export const SecretPolicyForm = ({
<Modal isOpen={isOpen} onOpenChange={onToggle}> <Modal isOpen={isOpen} onOpenChange={onToggle}>
<ModalContent title={isEditMode ? "Edit policy" : "Create policy"}> <ModalContent title={isEditMode ? "Edit policy" : "Create policy"}>
<form onSubmit={handleSubmit(handleFormSubmit)}> <form onSubmit={handleSubmit(handleFormSubmit)}>
<Controller
control={control}
name="name"
render={({ field, fieldState: { error } }) => (
<FormControl label="Policy Name" isError={Boolean(error)} errorText={error?.message}>
<Input {...field} value={field.value || ""} />
</FormControl>
)}
/>
<Controller <Controller
control={control} control={control}
name="environment" name="environment"
@@ -168,7 +183,7 @@ export const SecretPolicyForm = ({
name="approvers" name="approvers"
render={({ field: { value, onChange }, fieldState: { error } }) => ( render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl <FormControl
label="Approvals Required" label="Approvers Required"
isError={Boolean(error)} isError={Boolean(error)}
errorText={error?.message} errorText={error?.message}
> >
@@ -184,7 +199,7 @@ export const SecretPolicyForm = ({
style={{ width: "var(--radix-dropdown-menu-trigger-width)" }} style={{ width: "var(--radix-dropdown-menu-trigger-width)" }}
align="start" align="start"
> >
<DropdownMenuLabel>Select members that must approve changes</DropdownMenuLabel> <DropdownMenuLabel>Select members that are allowed to approve changes</DropdownMenuLabel>
{members.map(({ _id, user }) => { {members.map(({ _id, user }) => {
const isChecked = value?.includes(_id); const isChecked = value?.includes(_id);
return ( return (
@@ -0,0 +1,273 @@
import { Fragment, useState } from "react";
import {
faCheck,
faCheckCircle,
faChevronDown,
faCodeBranch
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { formatDistance } from "date-fns";
import { AnimatePresence, motion } from "framer-motion";
import {
Button,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuTrigger,
EmptyState,
Skeleton
} from "@app/components/v2";
import { useUser, useWorkspace } from "@app/context";
import {
useGetSecretApprovalRequestCount,
useGetSecretApprovalRequests,
useGetWorkspaceUsers
} from "@app/hooks/api";
import { ApprovalStatus, TSecretApprovalRequest, TWorkspaceUser } from "@app/hooks/api/types";
import {
generateCommitText,
SecretApprovalRequestChanges
} from "./components/SecretApprovalRequestChanges";
export const SecretApprovalRequest = () => {
const { currentWorkspace } = useWorkspace();
const workspaceId = currentWorkspace?._id || "";
const [selectedApproval, setSelectedApproval] = useState<TSecretApprovalRequest | null>(null);
// filters
const [statusFilter, setStatusFilter] = useState<"open" | "close">("open");
const [envFilter, setEnvFilter] = useState<string>();
const [committerFilter, setCommitterFilter] = useState<string>();
const {
data: secretApprovalRequests,
isFetchingNextPage: isFetchingNextApprovalRequest,
fetchNextPage: fetchNextApprovalRequest,
hasNextPage: hasNextApprovalPage,
isLoading: isApprovalRequestLoading,
refetch
} = useGetSecretApprovalRequests({
workspaceId,
status: statusFilter,
environment: envFilter,
committer: committerFilter
});
const { data: secretApprovalRequestCount, isSuccess: isSecretApprovalReqCountSuccess } =
useGetSecretApprovalRequestCount({ workspaceId });
const { user: presentUser } = useUser();
const { data: members } = useGetWorkspaceUsers(workspaceId);
const membersGroupById = members?.reduce<Record<string, TWorkspaceUser>>(
(prev, curr) => ({ ...prev, [curr._id]: curr }),
{}
);
const myMembershipId = members?.find(({ user }) => user._id === presentUser._id)?._id;
const isSecretApprovalScreen = Boolean(selectedApproval);
const handleGoBackSecretRequestDetail = () => {
setSelectedApproval(null);
refetch({ refetchPage: (_page, index) => index === 0 });
};
const isRequestListEmpty =
!isApprovalRequestLoading && secretApprovalRequests?.pages[0]?.length === 0;
return (
<AnimatePresence exitBeforeEnter>
{isSecretApprovalScreen ? (
<motion.div
key="approval-changes-details"
transition={{ duration: 0.1 }}
initial={{ opacity: 0, translateX: 30 }}
animate={{ opacity: 1, translateX: 0 }}
exit={{ opacity: 0, translateX: 30 }}
>
<SecretApprovalRequestChanges
workspaceId={workspaceId}
members={membersGroupById}
approvalRequestId={selectedApproval?._id || ""}
onGoBack={handleGoBackSecretRequestDetail}
committer={membersGroupById?.[selectedApproval?.committer || ""]}
/>
</motion.div>
) : (
<motion.div
key="approval-changes-list"
transition={{ duration: 0.1 }}
initial={{ opacity: 0, translateX: 30 }}
animate={{ opacity: 1, translateX: 0 }}
exit={{ opacity: 0, translateX: 30 }}
className="rounded-md text-gray-300"
>
<div className="p-4 px-8 flex items-center space-x-8 bg-mineshaft-800 rounded-t-md border-t border-x border-mineshaft-600">
<div
role="button"
tabIndex={0}
onClick={() => setStatusFilter("open")}
onKeyDown={(evt) => {
if (evt.key === "Enter") setStatusFilter("open");
}}
className={
statusFilter === "close" ? "text-gray-500 hover:text-gray-400 duration-100" : ""
}
>
<FontAwesomeIcon icon={faCodeBranch} className="mr-2" />
{isSecretApprovalReqCountSuccess && secretApprovalRequestCount?.open} Open
</div>
<div
className={
statusFilter === "open" ? "text-gray-500 hover:text-gray-400 duration-100" : ""
}
role="button"
tabIndex={0}
onClick={() => setStatusFilter("close")}
onKeyDown={(evt) => {
if (evt.key === "Enter") setStatusFilter("close");
}}
>
<FontAwesomeIcon icon={faCheck} className="mr-2" />
{isSecretApprovalReqCountSuccess && secretApprovalRequestCount.closed} Closed
</div>
<div className="flex-grow flex justify-end space-x-8">
<DropdownMenu>
<DropdownMenuTrigger>
<Button
variant="plain"
colorSchema="secondary"
className={envFilter ? "text-white" : "text-bunker-300"}
rightIcon={<FontAwesomeIcon icon={faChevronDown} size="sm" className="ml-2" />}
>
Environments
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuLabel>Select an environment</DropdownMenuLabel>
{currentWorkspace?.environments.map(({ slug, name }) => (
<DropdownMenuItem
onClick={() => setEnvFilter((state) => (state === slug ? undefined : slug))}
key={`request-filter-${slug}`}
icon={envFilter === slug && <FontAwesomeIcon icon={faCheckCircle} />}
iconPos="right"
>
{name}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
<DropdownMenu>
<DropdownMenuTrigger>
<Button
variant="plain"
colorSchema="secondary"
className={committerFilter ? "text-white" : "text-bunker-300"}
rightIcon={<FontAwesomeIcon icon={faChevronDown} size="sm" className="ml-2" />}
>
Author
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>Select an author</DropdownMenuLabel>
{members?.map(({ user, _id }) => (
<DropdownMenuItem
onClick={() =>
setCommitterFilter((state) => (state === _id ? undefined : _id))
}
key={`request-filter-member-${_id}`}
icon={committerFilter === _id && <FontAwesomeIcon icon={faCheckCircle} />}
iconPos="right"
>
{user.email}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
<div className="flex flex-col border-t border-mineshaft-600 bg-mineshaft-800 rounded-b-md border-b border-x border-mineshaft-600">
{isRequestListEmpty && (
<div className="py-12">
<EmptyState title="No more requests pending." />
</div>
)}
{secretApprovalRequests?.pages?.map((group, i) => (
<Fragment key={`secret-approval-request-${i + 1}`}>
{group?.map((secretApproval) => {
const {
_id: reqId,
commits,
committer,
createdAt,
policy,
reviewers,
status
} = secretApproval;
const isApprover = policy?.approvers?.indexOf(myMembershipId || "") !== -1;
const isReviewed =
reviewers.findIndex(
({ member, status: reviewStatus }) =>
member === myMembershipId && reviewStatus === ApprovalStatus.APPROVED
) !== -1;
return (
<div
key={reqId}
className="flex flex-col px-8 py-4 hover:bg-mineshaft-700"
role="button"
tabIndex={0}
onClick={() => setSelectedApproval(secretApproval)}
onKeyDown={(evt) => {
if (evt.key === "Enter") setSelectedApproval(secretApproval);
}}
>
<div className="mb-1">
<FontAwesomeIcon icon={faCodeBranch} className="mr-2" />
{generateCommitText(commits)}
<span className="text-xs text-bunker-300"> #{secretApproval.slug}</span>
</div>
<span className="text-xs text-gray-500">
Opened {formatDistance(new Date(createdAt), new Date())} ago by{" "}
{membersGroupById?.[committer]?.user?.firstName}{" "}
{membersGroupById?.[committer]?.user?.lastName} (
{membersGroupById?.[committer]?.user?.email}){" "}
{isApprover && !isReviewed && status === "open" && "- Review required"}
</span>
</div>
);
})}
</Fragment>
))}
{(isFetchingNextApprovalRequest || isApprovalRequestLoading) && (
<div>
{Array.apply(0, Array(3)).map((_x, index) => (
<div
key={`approval-request-loading-${index + 1}`}
className="flex flex-col px-8 py-4 hover:bg-mineshaft-700"
>
<div className="mb-2 flex items-center">
<FontAwesomeIcon icon={faCodeBranch} className="mr-2" />
<Skeleton className="bg-mineshaft-600 w-1/4" />
</div>
<Skeleton className="bg-mineshaft-600 w-1/2" />
</div>
))}
</div>
)}
</div>
{hasNextApprovalPage && (
<Button
className="mt-4 text-sm"
isFullWidth
variant="star"
isLoading={isFetchingNextApprovalRequest}
isDisabled={isFetchingNextApprovalRequest || !hasNextApprovalPage}
onClick={() => fetchNextApprovalRequest()}
>
{hasNextApprovalPage ? "Load More" : "End of history"}
</Button>
)}
</motion.div>
)}
</AnimatePresence>
);
};
@@ -0,0 +1,163 @@
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;
isMergable?: boolean;
status: "close" | "open";
approvals: number;
statusChangeByEmail: string;
workspaceId: string;
};
export const SecretApprovalRequestAction = ({
approvalRequestId,
hasMerged,
status,
isMergable,
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,
workspaceId
});
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 (
<div className="flex justify-between items-center w-full">
<div className="flex space-x-4 items-start">
<FontAwesomeIcon
icon={isMergable ? faSquareCheck : faSquareXmark}
className={twMerge("text-2xl pt-1", isMergable ? "text-primary" : "text-red-600")}
/>
<span className="flex flex-col">
{isMergable ? "Good to merge" : "Review required"}
<span className="inline-block text-xs text-bunker-200">
At least {approvals} approving review required
{Boolean(statusChangeByEmail) && `. Reopened by ${statusChangeByEmail}`}
</span>
</span>
</div>
<div className="flex items-center space-x-2">
<Button
onClick={() => handleSecretApprovalStatusChange("close")}
isLoading={isStatusChanging}
variant="outline_bg"
colorSchema="secondary"
leftIcon={<FontAwesomeIcon icon={faClose} />}
>
Close request
</Button>
<Button
leftIcon={<FontAwesomeIcon icon={faCheck} />}
isDisabled={!isMergable}
isLoading={isMerging}
onClick={handleSecretApprovalRequestMerge}
colorSchema="primary"
variant="solid"
>
Merge
</Button>
</div>
</div>
);
}
if (hasMerged && status === "close")
return (
<div className="flex justify-between items-center w-full">
<div className="flex space-x-4 items-start">
<FontAwesomeIcon icon={faCheck} className="text-2xl text-primary pt-1" />
<span className="flex flex-col">
Change request merged
<span className="inline-block text-xs text-bunker-200">
Merged by {statusChangeByEmail}
</span>
</span>
</div>
</div>
);
return (
<div className="flex justify-between items-center w-full">
<div className="flex space-x-4 items-start">
<FontAwesomeIcon icon={faUserLock} className="text-2xl text-primary pt-1" />
<span className="flex flex-col">
Change request has been closed
<span className="inline-block text-xs text-bunker-200">
Closed by {statusChangeByEmail}
</span>
</span>
</div>
<div className="flex items-center space-x-6">
<Button
onClick={() => handleSecretApprovalStatusChange("open")}
isLoading={isStatusChanging}
variant="outline_bg"
leftIcon={<FontAwesomeIcon icon={faLockOpen} />}
>
Reopen request
</Button>
</div>
</div>
);
};
@@ -0,0 +1,177 @@
import { faExclamationTriangle, faInfo } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
SecretInput,
Table,
TableContainer,
Tag,
TBody,
Td,
Th,
THead,
Tooltip,
Tr
} from "@app/components/v2";
import { CommitType, DecryptedSecret, TSecretApprovalSecChange, WsTag } from "@app/hooks/api/types";
export type Props = {
op: CommitType;
secretVersion?: DecryptedSecret;
newVersion?: Omit<TSecretApprovalSecChange, "tags"> & { tags?: WsTag[] };
presentSecretVersionNumber: number;
hasMerged?: Boolean;
conflicts: Array<{ secretId: string; op: CommitType }>;
};
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 (
<span>
Request for <span style={{ color: text.color }}>secret {text.label}</span>
</span>
);
};
const generateConflictText = (op: CommitType) => {
if (op === CommitType.CREATE) return <div>Secret already exist</div>;
if (op === CommitType.UPDATE) return <div>Secret not found</div>;
return null;
};
export const SecretApprovalRequestChangeItem = ({
op,
secretVersion,
newVersion,
presentSecretVersionNumber,
hasMerged,
conflicts
}: Props) => {
// meaning request has changed
const isStale = (secretVersion?.version || 1) < presentSecretVersionNumber;
const itemConflict =
hasMerged && conflicts.find((el) => el.op === op && el.secretId === newVersion?._id);
const hasConflict = Boolean(itemConflict);
return (
<div className="bg-bunker-500 rounded-lg pt-2 pb-4 px-4">
<div className="py-3 px-1 flex items-center">
<div className="flex-grow">{generateItemTitle(op)}</div>
{!hasMerged && isStale && (
<div className="flex items-center">
<FontAwesomeIcon icon={faInfo} className="text-primary-600 text-sm" />
<span className="text-xs ml-2">Secret has been changed(stale)</span>
</div>
)}
{hasMerged && hasConflict && (
<div className="flex items-center text-sm text-bunker-300 space-x-2">
<Tooltip content="Merge Conflict">
<FontAwesomeIcon icon={faExclamationTriangle} className="text-red-700" />
</Tooltip>
<div>{generateConflictText(op)}</div>
</div>
)}
</div>
<TableContainer>
<Table>
<THead>
<Tr>
{op === CommitType.UPDATE && <Th className="w-12" />}
<Th className="min-table-row">Secret</Th>
<Th>Value</Th>
<Th className="min-table-row">Comment</Th>
<Th className="min-table-row">Tags</Th>
</Tr>
</THead>
{op === CommitType.UPDATE ? (
<TBody>
<Tr>
<Td className="text-red-600">OLD</Td>
<Td>{secretVersion?.key}</Td>
<Td>
<SecretInput isReadOnly value={secretVersion?.value} />
</Td>
<Td>{secretVersion?.comment}</Td>
<Td>
{secretVersion?.tags?.map(({ name, _id: tagId, tagColor }) => (
<Tag
className="flex items-center space-x-2 w-min"
key={`${secretVersion._id}-${tagId}`}
>
<div
className="w-3 h-3 rounded-full"
style={{ backgroundColor: tagColor || "#bec2c8" }}
/>
<div className="text-sm">{name}</div>
</Tag>
))}
</Td>
</Tr>
<Tr>
<Td className="text-green-600">NEW</Td>
<Td>{newVersion?.secretKey}</Td>
<Td>
<SecretInput isReadOnly value={newVersion?.secretValue} />
</Td>
<Td>{newVersion?.secretComment}</Td>
<Td>
{newVersion?.tags?.map(({ name, _id: tagId, tagColor }) => (
<Tag
className="flex items-center space-x-2 w-min"
key={`${newVersion._id}-${tagId}`}
>
<div
className="w-3 h-3 rounded-full"
style={{ backgroundColor: tagColor || "#bec2c8" }}
/>
<div className="text-sm">{name}</div>
</Tag>
))}
</Td>
</Tr>
</TBody>
) : (
<TBody>
<Tr>
<Td>{op === CommitType.CREATE ? newVersion?.secretKey : secretVersion?.key}</Td>
<Td>
<SecretInput
isReadOnly
value={
op === CommitType.CREATE ? newVersion?.secretValue : secretVersion?.value
}
/>
</Td>
<Td>
{op === CommitType.CREATE ? newVersion?.secretComment : secretVersion?.comment}
</Td>
<Td>
{(op === CommitType.CREATE ? newVersion?.tags : secretVersion?.tags)?.map(
({ name, _id: tagId, tagColor }) => (
<Tag
className="flex items-center space-x-2 w-min"
key={`${
op === CommitType.CREATE ? newVersion?._id : secretVersion?._id
}-${tagId}`}
>
<div
className="w-3 h-3 rounded-full"
style={{ backgroundColor: tagColor || "#bec2c8" }}
/>
<div className="text-sm">{name}</div>
</Tag>
)
)}
</Td>
</Tr>
</TBody>
)}
</Table>
</TableContainer>
</div>
);
};
@@ -0,0 +1,302 @@
import { ReactNode } from "react";
import {
faArrowLeft,
faCheck,
faCheckCircle,
faCircle,
faCodeBranch,
faFolder,
faXmarkCircle
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
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,
useUpdateSecretApprovalReviewStatus
} from "@app/hooks/api";
import { ApprovalStatus, CommitType, TWorkspaceUser } from "@app/hooks/api/types";
import { SecretApprovalRequestAction } from "./SecretApprovalRequestAction";
import { SecretApprovalRequestChangeItem } from "./SecretApprovalRequestChangeItem";
export const generateCommitText = (commits: { op: CommitType }[] = []) => {
const score: Record<string, number> = {};
commits.forEach(({ op }) => {
score[op] = (score?.[op] || 0) + 1;
});
const text: ReactNode[] = [];
if (score[CommitType.CREATE])
text.push(
<span key="created-commit">
{score[CommitType.CREATE]} secret{score[CommitType.CREATE] !== 1 && "s"}
<span style={{ color: "#16a34a" }}> created</span>
</span>
);
if (score[CommitType.UPDATE])
text.push(
<span key="updated-commit">
{Boolean(text.length) && ","}
{score[CommitType.UPDATE]} secret{score[CommitType.UPDATE] !== 1 && "s"}
<span style={{ color: "#ea580c" }} className="text-orange-600">
{" "}
updated
</span>
</span>
);
if (score[CommitType.DELETE])
text.push(
<span className="deleted-commit">
{Boolean(text.length) && "and"}
{score[CommitType.DELETE]} secret{score[CommitType.UPDATE] !== 1 && "s"}
<span style={{ color: "#b91c1c" }}> deleted</span>
</span>
);
return text;
};
const getReviewedStatusSymbol = (status?: ApprovalStatus) => {
if (status === ApprovalStatus.APPROVED)
return <FontAwesomeIcon icon={faCheckCircle} size="xs" style={{ color: "#15803d" }} />;
if (status === ApprovalStatus.REJECTED)
return <FontAwesomeIcon icon={faXmarkCircle} size="xs" style={{ color: "#b91c1c" }} />;
return <FontAwesomeIcon icon={faCircle} size="xs" style={{ color: "#c2410c" }} />;
};
type Props = {
workspaceId: string;
approvalRequestId: string;
onGoBack: () => void;
committer?: TWorkspaceUser;
members?: Record<string, TWorkspaceUser>;
};
export const SecretApprovalRequestChanges = ({
approvalRequestId,
onGoBack,
committer,
workspaceId,
members = {}
}: Props) => {
const { createNotification } = useNotificationContext();
const { user } = useUser();
const { data: decryptFileKey } = useGetUserWsKey(workspaceId);
const {
data: secretApprovalRequestDetails,
isSuccess: isSecretApprovalRequestSuccess,
isLoading: isSecretApprovalRequestLoading
} = useGetSecretApprovalRequestDetails({
id: approvalRequestId,
decryptKey: decryptFileKey!
});
const {
mutateAsync: updateSecretApprovalRequestStatus,
isLoading: isUpdatingRequestStatus,
variables
} = useUpdateSecretApprovalReviewStatus();
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<string, ApprovalStatus>
>(
(prev, curr) => ({
...prev,
[curr.member]: curr.status
}),
{}
);
const hasApproved = reviewedMembers?.[myMembershipId] === ApprovalStatus.APPROVED;
const hasRejected = reviewedMembers?.[myMembershipId] === ApprovalStatus.REJECTED;
const handleSecretApprovalStatusUpdate = async (status: ApprovalStatus) => {
try {
await updateSecretApprovalRequestStatus({
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"
});
}
};
if (isSecretApprovalRequestLoading) {
return (
<div>
<ContentLoader />
</div>
);
}
if (!isSecretApprovalRequestSuccess)
return (
<div>
<EmptyState title="Failed to load approvals" />
</div>
);
const isMergable =
secretApprovalRequestDetails?.policy?.approvals <=
secretApprovalRequestDetails?.policy?.approvers?.filter(
(approverId) => reviewedMembers?.[approverId] === ApprovalStatus.APPROVED
).length;
const hasMerged = secretApprovalRequestDetails?.hasMerged;
return (
<div className="flex space-x-6">
<div className="flex-grow">
<div className="flex items-center space-x-4 pt-2 pb-6 sticky top-0 z-20 bg-bunker-800">
<IconButton variant="outline_bg" ariaLabel="go-back" onClick={onGoBack}>
<FontAwesomeIcon icon={faArrowLeft} />
</IconButton>
<div className="bg-red-600 text-white flex items-center space-x-2 px-4 py-2 rounded-3xl">
<FontAwesomeIcon icon={faCodeBranch} size="sm" />
<span>{secretApprovalRequestDetails.status}</span>
</div>
<div className="flex flex-col flex-grow">
<div className="text-lg mb-1">
{generateCommitText(secretApprovalRequestDetails.commits)}
</div>
<div className="text-sm text-bunker-300 flex items-center">
{committer?.user?.firstName}
{committer?.user?.lastName} ({committer?.user?.email}) wants to change{" "}
{secretApprovalRequestDetails.commits.length} secret values in
<span className="text-primary-300 bg-primary-600/60 px-1 mx-1 rounded">
{secretApprovalRequestDetails.environment}
</span>
<div className="flex items-center border border-mineshaft-500 pl-1 pr-2 rounded w-min">
<div className="border-r border-mineshaft-500 pr-1">
<FontAwesomeIcon icon={faFolder} className="text-primary" size="sm" />
</div>
<div className="text-sm pl-2 pb-0.5">{secretApprovalRequestDetails.secretPath}</div>
</div>
</div>
</div>
{!hasMerged && secretApprovalRequestDetails.status === "open" && (
<>
<Button
size="xs"
leftIcon={hasApproved && <FontAwesomeIcon icon={faCheck} />}
onClick={() => handleSecretApprovalStatusUpdate(ApprovalStatus.APPROVED)}
isLoading={isApproving}
isDisabled={isApproving || hasApproved}
>
{hasApproved ? "Approved" : "Approve"}
</Button>
<Button
size="xs"
colorSchema="danger"
leftIcon={hasRejected && <FontAwesomeIcon icon={faCheck} />}
onClick={() => handleSecretApprovalStatusUpdate(ApprovalStatus.REJECTED)}
isLoading={isRejecting}
isDisabled={isRejecting || hasRejected}
>
{hasRejected ? "Rejected" : "Reject"}
</Button>
</>
)}
</div>
<div className="flex flex-col space-y-4">
{secretApprovalRequestDetails.commits.map(
({ op, secretVersion, secret, newVersion }, index) => (
<SecretApprovalRequestChangeItem
op={op}
conflicts={secretApprovalRequestDetails.conflicts}
hasMerged={hasMerged}
secretVersion={secretVersion}
presentSecretVersionNumber={secret?.version || 0}
newVersion={newVersion}
key={`${op}-${index + 1}-${secretVersion?._id}`}
/>
)
)}
</div>
<div className="flex items-center px-5 py-6 rounded-lg space-x-6 bg-mineshaft-800 mt-8">
<SecretApprovalRequestAction
approvalRequestId={secretApprovalRequestDetails._id}
hasMerged={hasMerged}
approvals={secretApprovalRequestDetails.policy.approvals || 0}
status={secretApprovalRequestDetails.status}
isMergable={isMergable}
statusChangeByEmail={
members[secretApprovalRequestDetails?.statusChangeBy || ""]?.user?.email || ""
}
workspaceId={workspaceId}
/>
</div>
</div>
<div className="w-1/5 pt-4 sticky top-0" style={{ minWidth: "240px" }}>
<div className="text-sm text-bunker-300">Reviewers</div>
<div className="mt-2 flex flex-col space-y-2 text-sm">
{secretApprovalRequestDetails?.policy?.approvers.map((requiredApproverId) => {
const userDetails = members?.[requiredApproverId]?.user;
const status = reviewedMembers?.[requiredApproverId];
return (
<div
className="flex items-center space-x-2 flex-nowrap bg-mineshaft-800 px-2 py-1 rounded"
key={`required-approver-${requiredApproverId}`}
>
<div className="flex-grow text-sm">
<Tooltip content={`${userDetails.firstName} ${userDetails.lastName}`}>
<span>{userDetails?.email} </span>
</Tooltip>
<span className="text-red">*</span>
</div>
<div>
<Tooltip content={status || ApprovalStatus.PENDING}>
{getReviewedStatusSymbol(status)}
</Tooltip>
</div>
</div>
);
})}
{secretApprovalRequestDetails?.reviewers
.filter(
({ member }) => !secretApprovalRequestDetails?.policy?.approvers?.includes(member)
)
.map((reviewer) => {
const userDetails = members?.[reviewer.member]?.user;
const status = reviewedMembers?.[reviewer.status];
return (
<div
className="flex items-center space-x-2 flex-nowrap bg-mineshaft-800 px-2 py-1 rounded"
key={`required-approver-${reviewer.member}`}
>
<div className="flex-grow text-sm">
<Tooltip content={`${userDetails.firstName} ${userDetails.lastName}`}>
<span>{userDetails?.email} </span>
</Tooltip>
<span className="text-red">*</span>
</div>
<div>
<Tooltip content={status || ApprovalStatus.PENDING}>
{getReviewedStatusSymbol(status)}
</Tooltip>
</div>
</div>
);
})}
</div>
</div>
</div>
);
};
@@ -0,0 +1 @@
export { SecretApprovalRequest } from "./SecretApprovalRequest";
@@ -19,6 +19,7 @@ import {
useGetImportedSecrets, useGetImportedSecrets,
useGetProjectFolders, useGetProjectFolders,
useGetProjectSecrets, useGetProjectSecrets,
useGetSecretApprovalPolicyOfABoard,
useGetSecretImports, useGetSecretImports,
useGetUserWsKey, useGetUserWsKey,
useGetWorkspaceSnapshotList, useGetWorkspaceSnapshotList,
@@ -118,6 +119,13 @@ export const SecretMainPage = () => {
// fetch tags // fetch tags
const { data: tags } = useGetWsTags(canReadSecret ? workspaceId : ""); const { data: tags } = useGetWsTags(canReadSecret ? workspaceId : "");
const { data: boardPolicy } = useGetSecretApprovalPolicyOfABoard({
workspaceId,
environment,
secretPath
});
const isProtectedBranch = Boolean(boardPolicy);
const { const {
data: snapshotList, data: snapshotList,
isFetchingNextPage: isFetchingNextSnapshotList, isFetchingNextPage: isFetchingNextSnapshotList,
@@ -207,6 +215,8 @@ export const SecretMainPage = () => {
secretPath={secretPath} secretPath={secretPath}
isProjectRelated isProjectRelated
onEnvChange={handleEnvChange} onEnvChange={handleEnvChange}
isProtectedBranch={isProtectedBranch}
protectionPolicyName={boardPolicy?.name}
/> />
</div> </div>
{!isRollbackMode ? ( {!isRollbackMode ? (
@@ -281,6 +291,7 @@ export const SecretMainPage = () => {
workspaceId={workspaceId} workspaceId={workspaceId}
secretPath={secretPath} secretPath={secretPath}
decryptFileKey={decryptFileKey!} decryptFileKey={decryptFileKey!}
isProtectedBranch={isProtectedBranch}
/> />
)} )}
{!canReadSecret && folders?.length === 0 && <PermissionDeniedBanner />} {!canReadSecret && folders?.length === 0 && <PermissionDeniedBanner />}
@@ -292,6 +303,7 @@ export const SecretMainPage = () => {
decryptFileKey={decryptFileKey!} decryptFileKey={decryptFileKey!}
secretPath={secretPath} secretPath={secretPath}
autoCapitalize={currentWorkspace?.autoCapitalization} autoCapitalize={currentWorkspace?.autoCapitalization}
isProtectedBranch={isProtectedBranch}
/> />
<SecretDropzone <SecretDropzone
secrets={secrets} secrets={secrets}
@@ -301,6 +313,7 @@ export const SecretMainPage = () => {
secretPath={secretPath} secretPath={secretPath}
isSmaller={isNotEmtpy} isSmaller={isNotEmtpy}
environments={currentWorkspace?.environments} environments={currentWorkspace?.environments}
isProtectedBranch={isProtectedBranch}
/> />
<PitDrawer <PitDrawer
secretSnaphots={snapshotList} secretSnaphots={snapshotList}
@@ -23,6 +23,7 @@ type Props = {
secretPath?: string; secretPath?: string;
// modal props // modal props
autoCapitalize?: boolean; autoCapitalize?: boolean;
isProtectedBranch?: boolean;
}; };
export const CreateSecretForm = ({ export const CreateSecretForm = ({
@@ -30,7 +31,8 @@ export const CreateSecretForm = ({
workspaceId, workspaceId,
decryptFileKey, decryptFileKey,
secretPath = "/", secretPath = "/",
autoCapitalize = true autoCapitalize = true,
isProtectedBranch = false
}: Props) => { }: Props) => {
const { const {
register, register,
@@ -62,7 +64,9 @@ export const CreateSecretForm = ({
reset(); reset();
createNotification({ createNotification({
type: "success", type: "success",
text: "Successfully created secret" text: isProtectedBranch
? "Requested changes have been sent for review"
: "Successfully created secret"
}); });
} catch (error) { } catch (error) {
console.log(error); console.log(error);
@@ -43,6 +43,7 @@ type Props = {
environment: string; environment: string;
secretPath: string; secretPath: string;
secrets?: DecryptedSecret[]; secrets?: DecryptedSecret[];
isProtectedBranch?: boolean;
}; };
export const SecretDropzone = ({ export const SecretDropzone = ({
@@ -52,7 +53,8 @@ export const SecretDropzone = ({
decryptFileKey, decryptFileKey,
environment, environment,
secretPath, secretPath,
secrets = [] secrets = [],
isProtectedBranch = false
}: Props): JSX.Element => { }: Props): JSX.Element => {
const { t } = useTranslation(); const { t } = useTranslation();
const [isDragActive, setDragActive] = useToggle(); const [isDragActive, setDragActive] = useToggle();
@@ -195,7 +197,9 @@ export const SecretDropzone = ({
handlePopUpClose("overlapKeyWarning"); handlePopUpClose("overlapKeyWarning");
createNotification({ createNotification({
type: "success", type: "success",
text: "Successfully uploaded secrets" text: isProtectedBranch
? "Uploaded changes have been sent for review"
: "Successfully uploaded secrets"
}); });
} catch (err) { } catch (err) {
console.log(err); console.log(err);
@@ -49,7 +49,8 @@ type Props = {
onDeleteSecret: () => void; onDeleteSecret: () => void;
onSaveSecret: ( onSaveSecret: (
orgSec: DecryptedSecret, orgSec: DecryptedSecret,
modSec: Omit<DecryptedSecret, "tags"> & { tags: { _id: string }[] } modSec: Omit<DecryptedSecret, "tags"> & { tags: { _id: string }[] },
cb?: () => void
) => Promise<void>; ) => Promise<void>;
tags: WsTag[]; tags: WsTag[];
onCreateTag: () => void; onCreateTag: () => void;
@@ -143,7 +144,7 @@ export const SecretDetailSidebar = ({
}; };
const handleFormSubmit = async (data: TFormSchema) => { const handleFormSubmit = async (data: TFormSchema) => {
await onSaveSecret(secret, { ...secret, ...data }); await onSaveSecret(secret, { ...secret, ...data }, () => reset());
}; };
return ( return (
@@ -54,7 +54,8 @@ type Props = {
secret: DecryptedSecret; secret: DecryptedSecret;
onSaveSecret: ( onSaveSecret: (
orgSec: DecryptedSecret, orgSec: DecryptedSecret,
modSec: Omit<DecryptedSecret, "tags"> & { tags: { _id: string }[] } modSec: Omit<DecryptedSecret, "tags"> & { tags: { _id: string }[] },
cb?: () => void
) => Promise<void>; ) => Promise<void>;
onDeleteSecret: (sec: DecryptedSecret) => void; onDeleteSecret: (sec: DecryptedSecret) => void;
onDetailViewSecret: (sec: DecryptedSecret) => void; onDetailViewSecret: (sec: DecryptedSecret) => void;
@@ -148,13 +149,14 @@ export const SecretItem = memo(
); );
setValue("valueOverride", secret?.valueOverride, { shouldDirty: !isUnsavedOverride }); setValue("valueOverride", secret?.valueOverride, { shouldDirty: !isUnsavedOverride });
} else { } else {
reset();
setValue("overrideAction", SecretActionType.Modified, { shouldDirty: true }); setValue("overrideAction", SecretActionType.Modified, { shouldDirty: true });
setValue("valueOverride", "", { shouldDirty: true }); setValue("valueOverride", "", { shouldDirty: true });
} }
}; };
const handleFormSubmit = async (data: TFormSchema) => { const handleFormSubmit = async (data: TFormSchema) => {
await onSaveSecret(secret, { ...secret, ...data }); await onSaveSecret(secret, { ...secret, ...data }, () => reset());
}; };
const handleTagSelect = (tag: WsTag) => { const handleTagSelect = (tag: WsTag) => {
@@ -27,6 +27,7 @@ type Props = {
sortDir?: SortDir; sortDir?: SortDir;
tags?: WsTag[]; tags?: WsTag[];
isVisible?: boolean; isVisible?: boolean;
isProtectedBranch?: boolean;
}; };
const reorderSecretGroupByUnderscore = (secrets: DecryptedSecret[], sortDir: SortDir) => { const reorderSecretGroupByUnderscore = (secrets: DecryptedSecret[], sortDir: SortDir) => {
@@ -84,7 +85,8 @@ export const SecretListView = ({
filter, filter,
sortDir = SortDir.ASC, sortDir = SortDir.ASC,
tags: wsTags = [], tags: wsTags = [],
isVisible isVisible,
isProtectedBranch = false
}: Props) => { }: Props) => {
const { createNotification } = useNotificationContext(); const { createNotification } = useNotificationContext();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
@@ -178,7 +180,8 @@ export const SecretListView = ({
const handleSaveSecret = useCallback( const handleSaveSecret = useCallback(
async ( async (
orgSecret: DecryptedSecret, orgSecret: DecryptedSecret,
modSecret: Omit<DecryptedSecret, "tags"> & { tags: { _id: string }[] } modSecret: Omit<DecryptedSecret, "tags"> & { tags: { _id: string }[] },
cb?: () => void
) => { ) => {
const { key: oldKey } = orgSecret; const { key: oldKey } = orgSecret;
const { key, value, overrideAction, idOverride, valueOverride, tags, comment } = modSecret; const { key, value, overrideAction, idOverride, valueOverride, tags, comment } = modSecret;
@@ -193,6 +196,19 @@ export const SecretListView = ({
) && isSameTags; ) && isSameTags;
try { 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 // shared secret change
if (!isSharedSecUnchanged) { if (!isSharedSecUnchanged) {
await handleSecretOperation("update", "shared", oldKey, { await handleSecretOperation("update", "shared", oldKey, {
@@ -202,19 +218,7 @@ export const SecretListView = ({
newKey: hasKeyChanged ? key : undefined, newKey: hasKeyChanged ? key : undefined,
skipMultilineEncoding: modSecret.skipMultilineEncoding skipMultilineEncoding: modSecret.skipMultilineEncoding
}); });
} if (cb) cb();
// 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 });
} }
queryClient.invalidateQueries( queryClient.invalidateQueries(
@@ -229,7 +233,9 @@ export const SecretListView = ({
handlePopUpClose("secretDetail"); handlePopUpClose("secretDetail");
createNotification({ createNotification({
type: "success", type: "success",
text: "Successfully saved secrets" text: isProtectedBranch
? "Requested changes have been sent for review"
: "Successfully saved secrets"
}); });
} catch (error) { } catch (error) {
console.log(error); console.log(error);
@@ -239,7 +245,7 @@ export const SecretListView = ({
}); });
} }
}, },
[environment, secretPath] [environment, secretPath, isProtectedBranch]
); );
const handleSecretDelete = useCallback(async () => { const handleSecretDelete = useCallback(async () => {
@@ -259,7 +265,9 @@ export const SecretListView = ({
handlePopUpClose("secretDetail"); handlePopUpClose("secretDetail");
createNotification({ createNotification({
type: "success", type: "success",
text: "Successfully deleted secret" text: isProtectedBranch
? "Requested changes have been sent for review"
: "Successfully deleted secret"
}); });
} catch (error) { } catch (error) {
console.log(error); console.log(error);