feat(secret-approval): implemented the base

This commit is contained in:
Akhil Mohan
2023-10-05 20:33:48 +05:30
parent a995627815
commit fc43511f5d
22 changed files with 1467 additions and 49 deletions
+3 -2
View File
@@ -17,7 +17,7 @@ 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"; import * as secretApprovalPolicyController from "./secretApprovalPolicyController";
import * as secretApprovalRequestController from "./secretApprovalRequestsController";
export { export {
authController, authController,
botController, botController,
@@ -37,5 +37,6 @@ export {
secretScanningController, secretScanningController,
webhookController, webhookController,
secretImpsController, secretImpsController,
secretApprovalPolicyController secretApprovalPolicyController,
secretApprovalRequestController
}; };
@@ -0,0 +1,128 @@
import { Request, Response } from "express";
import { getUserProjectPermissions } from "../../ee/services/ProjectRoleService";
import { validateRequest } from "../../helpers/validation";
import { Folder } from "../../models";
import { SecretApprovalRequest } from "../../models/secretApprovalRequest";
import * as reqValidator from "../../validation/secretApprovalRequest";
import { getFolderWithPathFromId } from "../../services/FolderService";
import { BadRequestError, UnauthorizedRequestError } from "../../utils/errors";
import { ISecretApprovalPolicy } from "../../models/secretApprovalPolicy";
export const getSecretApprovalRequests = async (req: Request, res: Response) => {
const {
query: { status, committer, workspaceId, environment, limit, offset }
} = await validateRequest(reqValidator.getSecretApprovalRequests, req);
const { membership } = await getUserProjectPermissions(req.user._id, workspaceId);
const query = {
workspace: workspaceId,
environment,
committer,
status,
...(membership.role !== "admin"
? { $or: [{ committer: membership.id }, { "policy.approvers": membership.id }] }
: {})
};
// to strip of undefined in query we use es6 spread to ignore those fields
Object.entries(query).forEach(
([key, value]) => value === undefined && delete query[key as keyof typeof query]
);
const approvalRequests = await SecretApprovalRequest.find(query)
.limit(limit)
.skip(offset)
.populate("policy")
.lean();
if (!approvalRequests.length) return res.send({ requests: [] });
const unqiueEnvs = environment ?? {
$in: [...new Set(approvalRequests.map(({ environment }) => environment))]
};
const approvalRootFolders = await Folder.find({
workspace: workspaceId,
environment: unqiueEnvs
}).lean();
const formatedApprovals = approvalRequests.map((el) => {
let secretPath = "/";
const folders = approvalRootFolders.find(({ environment }) => environment === el.environment);
if (folders) {
secretPath = getFolderWithPathFromId(folders?.nodes, el.folderId)?.folderPath || "/";
}
return { ...el, secretPath };
});
return res.send({
approvals: formatedApprovals
});
};
export const getSecretApprovalRequestDetails = async (req: Request, res: Response) => {
const {
params: { id }
} = await validateRequest(reqValidator.getSecretApprovalRequestDetails, req);
const secretApprovalRequest = await SecretApprovalRequest.findById(id)
.populate("policy")
.populate({
path: "commits.secret",
populate: {
path: "tags"
}
})
.populate("commits.newVersion.tags");
if (!secretApprovalRequest)
throw BadRequestError({ message: "Secret approval request not found" });
const { membership } = await getUserProjectPermissions(
req.user._id,
secretApprovalRequest.workspace.toString()
);
// allow to fetch only if its admin or is the committer or approver
if (
membership.role !== "admin" &&
secretApprovalRequest.committer !== membership.id &&
secretApprovalRequest.reviewers.find(({ member }) => member === membership.id)
) {
throw UnauthorizedRequestError({ message: "User has no access" });
}
return res.send({
approval: secretApprovalRequest
});
};
export const updateSecretApprovalRequestStatus = async (req: Request, res: Response) => {
const {
body: { status },
params: { id }
} = await validateRequest(reqValidator.updateSecretApprovalRequestStatus, req);
const secretApprovalRequest = await SecretApprovalRequest.findById(id).populate<{
policy: ISecretApprovalPolicy;
}>("policy");
if (!secretApprovalRequest)
throw BadRequestError({ message: "Secret approval request not found" });
const { membership } = await getUserProjectPermissions(
req.user._id,
secretApprovalRequest.workspace.toString()
);
if (
membership.role !== "admin" &&
secretApprovalRequest.committer !== membership.id &&
!secretApprovalRequest.policy.approvers.find((approverId) => approverId === membership.id)
) {
throw UnauthorizedRequestError({ message: "User has no access" });
}
const reviewerPos = secretApprovalRequest.reviewers.findIndex(
({ member }) => member.toString() === membership._id.toString()
);
if (reviewerPos !== -1) {
secretApprovalRequest.reviewers[reviewerPos].status = status;
} else {
secretApprovalRequest.reviewers.push({ member: membership._id, status });
}
await secretApprovalRequest.save();
return res.send({ status });
};
+145 -15
View File
@@ -12,6 +12,7 @@ import { encryptSymmetric128BitHexKeyUTF8 } from "../../utils/crypto";
import { getAllImportedSecrets } from "../../services/SecretImportService"; import { getAllImportedSecrets } from "../../services/SecretImportService";
import { import {
Folder, Folder,
IMembership,
IServiceTokenData, IServiceTokenData,
IServiceTokenDataV3 IServiceTokenDataV3
} from "../../models"; } from "../../models";
@@ -49,7 +50,7 @@ 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 +76,19 @@ 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 +98,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 +109,25 @@ 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();
} }
} }
} }
import {
generateSecretApprovalRequest,
getSecretPolicyOfBoard
} from "../../services/SecretApprovalService";
import { CommitType } from "../../models/secretApprovalRequest";
import { IRole } from "../../ee/models/role";
/** /**
* Return secrets for workspace with id [workspaceId] and environment * Return secrets for workspace with id [workspaceId] and environment
@@ -160,7 +167,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 +482,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 +587,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 +595,35 @@ export const createSecret = async (req: Request, res: Response) => {
secretAction: ProjectPermissionActions.Create secretAction: ProjectPermissionActions.Create
}); });
const secretApprovalPolicy = await getSecretPolicyOfBoard(workspaceId, environment, secretPath);
if (secretApprovalPolicy && membership) {
const secretApprovalRequest = await generateSecretApprovalRequest({
workspaceId,
environment,
secretPath,
policy: secretApprovalPolicy,
commiterMembershipId: membership._id.toString(),
data: {
[CommitType.CREATE]: [
{
secretName,
secretValueCiphertext,
secretValueIV,
secretValueTag,
secretCommentIV,
secretCommentTag,
secretCommentCiphertext,
skipMultilineEncoding,
secretKeyTag,
secretKeyCiphertext,
secretKeyIV
}
]
}
});
return res.send({ approval: secretApprovalRequest });
}
const secret = await SecretService.createSecret({ const secret = await SecretService.createSecret({
secretName, secretName,
workspaceId: new Types.ObjectId(workspaceId), workspaceId: new Types.ObjectId(workspaceId),
@@ -656,7 +692,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 +700,36 @@ export const updateSecretByName = async (req: Request, res: Response) => {
secretAction: ProjectPermissionActions.Edit secretAction: ProjectPermissionActions.Edit
}); });
const secretApprovalPolicy = await getSecretPolicyOfBoard(workspaceId, environment, secretPath);
if (secretApprovalPolicy && membership) {
const secretApprovalRequest = await generateSecretApprovalRequest({
workspaceId,
environment,
secretPath,
policy: secretApprovalPolicy,
commiterMembershipId: membership._id.toString(),
data: {
[CommitType.UPDATE]: [
{
secretName,
secretValueCiphertext,
secretValueIV,
secretValueTag,
tags,
secretCommentIV,
secretCommentTag,
secretCommentCiphertext,
skipMultilineEncoding,
secretKeyTag,
secretKeyCiphertext,
secretKeyIV
}
]
}
});
return res.send({ approval: secretApprovalRequest });
}
const secret = await SecretService.updateSecret({ const secret = await SecretService.updateSecret({
secretName, secretName,
workspaceId: new Types.ObjectId(workspaceId), workspaceId: new Types.ObjectId(workspaceId),
@@ -709,7 +775,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 +783,25 @@ export const deleteSecretByName = async (req: Request, res: Response) => {
secretAction: ProjectPermissionActions.Delete secretAction: ProjectPermissionActions.Delete
}); });
const secretApprovalPolicy = await getSecretPolicyOfBoard(workspaceId, environment, secretPath);
if (secretApprovalPolicy && membership) {
const secretApprovalRequest = await generateSecretApprovalRequest({
workspaceId,
environment,
secretPath,
policy: secretApprovalPolicy,
commiterMembershipId: membership._id.toString(),
data: {
[CommitType.DELETE]: [
{
secretName
}
]
}
});
return res.send({ approval: secretApprovalRequest });
}
const { secret } = await SecretService.deleteSecret({ const { secret } = await SecretService.deleteSecret({
secretName, secretName,
workspaceId: new Types.ObjectId(workspaceId), workspaceId: new Types.ObjectId(workspaceId),
@@ -744,7 +829,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 +837,21 @@ export const createSecretByNameBatch = async (req: Request, res: Response) => {
secretAction: ProjectPermissionActions.Create secretAction: ProjectPermissionActions.Create
}); });
const secretApprovalPolicy = await getSecretPolicyOfBoard(workspaceId, environment, secretPath);
if (secretApprovalPolicy && membership) {
const secretApprovalRequest = await generateSecretApprovalRequest({
workspaceId,
environment,
secretPath,
policy: secretApprovalPolicy,
commiterMembershipId: membership._id.toString(),
data: {
[CommitType.CREATE]: secrets
}
});
return res.send({ approval: secretApprovalRequest });
}
const createdSecrets = await SecretService.createSecretBatch({ const createdSecrets = await SecretService.createSecretBatch({
secretPath, secretPath,
environment, environment,
@@ -770,7 +870,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 +878,21 @@ export const updateSecretByNameBatch = async (req: Request, res: Response) => {
secretAction: ProjectPermissionActions.Edit secretAction: ProjectPermissionActions.Edit
}); });
const secretApprovalPolicy = await getSecretPolicyOfBoard(workspaceId, environment, secretPath);
if (secretApprovalPolicy && membership) {
const secretApprovalRequest = await generateSecretApprovalRequest({
workspaceId,
environment,
secretPath,
policy: secretApprovalPolicy,
commiterMembershipId: membership._id.toString(),
data: {
[CommitType.UPDATE]: secrets
}
});
return res.send({ approval: secretApprovalRequest });
}
const updatedSecrets = await SecretService.updateSecretBatch({ const updatedSecrets = await SecretService.updateSecretBatch({
secretPath, secretPath,
environment, environment,
@@ -796,7 +911,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 +919,21 @@ export const deleteSecretByNameBatch = async (req: Request, res: Response) => {
secretAction: ProjectPermissionActions.Delete secretAction: ProjectPermissionActions.Delete
}); });
const secretApprovalPolicy = await getSecretPolicyOfBoard(workspaceId, environment, secretPath);
if (secretApprovalPolicy && membership) {
const secretApprovalRequest = await generateSecretApprovalRequest({
workspaceId,
environment,
secretPath,
policy: secretApprovalPolicy,
commiterMembershipId: membership._id.toString(),
data: {
[CommitType.DELETE]: secrets
}
});
return res.send({ approval: secretApprovalRequest });
}
const deletedSecrets = await SecretService.deleteSecretBatch({ const deletedSecrets = await SecretService.deleteSecretBatch({
secretPath, secretPath,
environment, environment,
+2
View File
@@ -43,6 +43,7 @@ import {
password as v1PasswordRouter, password as v1PasswordRouter,
sso as v1SSORouter, sso as v1SSORouter,
secretApprovalPolicy as v1SecretApprovalPolicy, secretApprovalPolicy as v1SecretApprovalPolicy,
secretApprovalRequest as v1SecretApprovalRequest,
secretImps as v1SecretImpsRouter, secretImps as v1SecretImpsRouter,
secret as v1SecretRouter, secret as v1SecretRouter,
secretsFolder as v1SecretsFolder, secretsFolder as v1SecretsFolder,
@@ -183,6 +184,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);
+130 -26
View File
@@ -1,58 +1,162 @@
import { Schema, Types, model } from "mongoose"; import { Schema, Types, model } from "mongoose";
import { ISecretVersion, SecretVersion } from "../ee/models/secretVersion"; import { ALGORITHM_AES_256_GCM, ENCODING_SCHEME_BASE64, ENCODING_SCHEME_UTF8 } from "../variables";
enum ApprovalStatus { export enum ApprovalStatus {
PENDING = "pending", PENDING = "pending",
APPROVED = "approved", APPROVED = "approved",
REJECTED = "rejected" REJECTED = "rejected"
} }
enum CommitType { export enum CommitType {
DELETE = "delete", DELETE = "delete",
UPDATE = "update", UPDATE = "update",
CREATE = "create" CREATE = "create"
} }
export interface ISecretApprovalSecChange {
_id: Types.ObjectId;
version: number;
secretBlindIndex?: string;
secretKeyCiphertext: string;
secretKeyIV: string;
secretKeyTag: string;
secretValueCiphertext: string;
secretValueIV: string;
secretValueTag: string;
secretCommentIV?: string;
secretCommentTag?: string;
secretCommentCiphertext?: string;
skipMultilineEncoding?: boolean;
algorithm?: "aes-256-gcm";
keyEncoding?: "utf8" | "base64";
tags?: string[];
}
export interface ISecretApprovalRequest { export interface ISecretApprovalRequest {
_id: Types.ObjectId; _id: Types.ObjectId;
committer: Types.ObjectId; committer: Types.ObjectId;
approvers: { reviewers: {
member: Types.ObjectId; member: Types.ObjectId;
status: ApprovalStatus; status: ApprovalStatus;
}[]; }[];
approvals: number; workspace: Types.ObjectId;
environment: string;
folderId: string;
hasMerged: boolean; hasMerged: boolean;
status: ApprovalStatus; status: "open" | "close";
commits: { policy: Types.ObjectId;
secretVersion: Types.ObjectId; commits: Array<
newVersion: ISecretVersion; | {
op: CommitType; newVersion: ISecretApprovalSecChange;
}[]; op: CommitType.CREATE;
}
| {
secret: Types.ObjectId;
newVersion: Partial<ISecretApprovalSecChange>;
op: CommitType.UPDATE;
}
| {
secret: Types.ObjectId;
op: CommitType.DELETE;
}
>;
} }
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>( const secretApprovalRequestSchema = new Schema<ISecretApprovalRequest>(
{ {
approvers: [ workspace: {
{ type: Schema.Types.ObjectId,
member: { ref: "Workspace",
// 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 required: true
}, },
environment: {
type: String,
required: true
},
folderId: {
type: String,
required: true,
default: "root"
},
reviewers: {
type: [
{
member: {
// user associated with the personal secret
type: Schema.Types.ObjectId,
ref: "Membership"
},
status: { type: String, enum: ApprovalStatus, default: ApprovalStatus.PENDING }
}
],
default: []
},
policy: { type: Schema.Types.ObjectId, ref: "SecretApprovalPolicy" },
hasMerged: { type: Boolean, default: false }, hasMerged: { type: Boolean, default: false },
status: { type: String, enum: ApprovalStatus, default: ApprovalStatus.PENDING }, status: { type: String, enum: ["close", "open"], default: "open" },
committer: { type: Schema.Types.ObjectId, ref: "Membership" }, committer: { type: Schema.Types.ObjectId, ref: "Membership" },
commits: [ commits: [
{ {
secretVersion: { type: Types.ObjectId, ref: "SecretVersion" }, secret: { type: Types.ObjectId, ref: "Secret" },
newVersion: SecretVersion, newVersion: secretApprovalSecretChangeSchema,
op: { type: String, enum: [CommitType], required: true } op: { type: String, enum: [CommitType], required: true }
} }
] ]
+3 -1
View File
@@ -19,6 +19,7 @@ import secretsFolder from "./secretsFolder";
import webhooks from "./webhook"; import webhooks from "./webhook";
import secretImps from "./secretImps"; import secretImps from "./secretImps";
import secretApprovalPolicy from "./secretApprovalPolicy"; import secretApprovalPolicy from "./secretApprovalPolicy";
import secretApprovalRequest from "./secretApprovalRequest";
export { export {
signup, signup,
@@ -41,5 +42,6 @@ export {
webhooks, webhooks,
secretImps, secretImps,
sso, sso,
secretApprovalPolicy secretApprovalPolicy,
secretApprovalRequest
}; };
@@ -0,0 +1,31 @@
import express from "express";
const router = express.Router();
import { requireAuth } from "../../middleware";
import { secretApprovalRequestController } from "../../controllers/v1";
import { AuthMode } from "../../variables";
router.get(
"/",
requireAuth({
acceptedAuthModes: [AuthMode.JWT]
}),
secretApprovalRequestController.getSecretApprovalRequests
);
router.get(
"/:id",
requireAuth({
acceptedAuthModes: [AuthMode.JWT]
}),
secretApprovalRequestController.getSecretApprovalRequestDetails
);
router.post(
"/:id",
requireAuth({
acceptedAuthModes: [AuthMode.JWT]
}),
secretApprovalRequestController.updateSecretApprovalRequestStatus
);
export default router;
@@ -0,0 +1,272 @@
import picomatch from "picomatch";
import { Types } from "mongoose";
import {
containsGlobPatterns,
generateSecretBlindIndexWithSaltHelper,
getSecretBlindIndexSaltHelper
} from "../helpers/secrets";
import { Folder, ISecret, Secret } from "../models";
import { ISecretApprovalPolicy, SecretApprovalPolicy } from "../models/secretApprovalPolicy";
import {
CommitType,
ISecretApprovalRequest,
ISecretApprovalSecChange,
SecretApprovalRequest
} from "../models/secretApprovalRequest";
import { BadRequestError } from "../utils/errors";
import { getFolderByPath } from "./FolderService";
import { SECRET_SHARED } from "../variables";
// if glob pattern score is 1, if not exist score is 0 and if its not both then its exact path meaning score 2
const getPolicyScore = (policy: ISecretApprovalPolicy) =>
policy.secretPath ? (containsGlobPatterns(policy.secretPath) ? 1 : 2) : 0;
// this will fetch the policy that gets priority for an environment and secret path
export const getSecretPolicyOfBoard = async (
workspaceId: string,
environment: string,
secretPath: string
) => {
const policies = await SecretApprovalPolicy.find({ workspace: workspaceId, environment });
if (!policies) return;
// this will filter policies either without scoped to secret path or the one that matches with secret path
const policiesFilteredByPath = policies.filter(
({ secretPath: policyPath }) =>
!policyPath || picomatch.isMatch(secretPath, policyPath, { strictSlashes: false })
);
// now sort by priority. exact secret path gets first match followed by glob followed by just env scoped
// if that is tie get by first createdAt
const policiesByPriority = policiesFilteredByPath.sort(
(a, b) => getPolicyScore(a) - getPolicyScore(b)
);
const finalPolicy = policiesByPriority.shift();
return finalPolicy;
};
type TApprovalCreateSecret = Omit<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;
};
export const generateSecretApprovalRequest = async ({
workspaceId,
environment,
secretPath,
policy,
data,
commiterMembershipId
}: TGenerateSecretApprovalRequestArg) => {
// calculate folder id from secret path
let folderId = "root";
const rootFolder = await Folder.findOne({ workspace: workspaceId, environment });
if (!rootFolder && secretPath !== "/") throw BadRequestError({ message: "Folder not found" });
if (rootFolder) {
const folder = getFolderByPath(rootFolder.nodes, secretPath);
if (!folder) throw BadRequestError({ message: "Folder not found" });
folderId = folder.id;
}
// generate secret blindIndexes
const salt = await getSecretBlindIndexSaltHelper({
workspaceId: new Types.ObjectId(workspaceId)
});
const commits: ISecretApprovalRequest["commits"] = [];
// -----
// for created secret approval change
const createdSecret = data[CommitType.CREATE];
if (createdSecret && createdSecret?.length) {
// validation checks whether secret exists for creation
const secretBlindIndexes = await Promise.all(
createdSecret.map(({ secretName }) =>
generateSecretBlindIndexWithSaltHelper({
secretName,
salt
})
)
).then((blindIndexes) =>
blindIndexes.reduce<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 secretsToBeUpdated = await Secret.find({
workspace: new Types.ObjectId(workspaceId),
folder: folderId,
environment
})
.select("+secretBlindIndex")
.or(
updatedSecret.map(({ secretName }) => ({
secretBlindIndex: secretBlindIndexes[secretName],
type: SECRET_SHARED
}))
)
.lean()
.exec();
if (secretsToBeUpdated.length !== updatedSecret.length)
throw BadRequestError({ message: "Secrets already exist" });
// finally check updating blindindex exist
const nameUpdatedSecrets = updatedSecret.filter(({ newSecretName }) => Boolean(newSecretName));
const newSecretBlindIndexes = await Promise.all(
nameUpdatedSecrets.map(({ newSecretName }) =>
generateSecretBlindIndexWithSaltHelper({
secretName: newSecretName as string,
salt
})
)
).then((blindIndexes) =>
blindIndexes.reduce<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)
throw BadRequestError({ message: "Secret with new name already exist" });
commits.push(
...updatedSecret.map((el) => {
const oldSecret = secretsToBeUpdated.find(
(sec) => sec?.secretBlindIndex === secretBlindIndexes[el.secretName]
);
if (!oldSecret) throw BadRequestError({ message: "Secret not found" });
return {
op: CommitType.UPDATE as const,
secret: oldSecret._id,
newVersion: {
...el,
secretBlindIndex: newSecretBlindIndexes?.[el.secretName],
_id: new Types.ObjectId(),
version: oldSecret.version || 1
}
};
})
);
}
// -----
// deleted secrets
const deletedSecrets = data[CommitType.DELETE];
if (deletedSecrets && deletedSecrets.length) {
const secretBlindIndexes = await Promise.all(
deletedSecrets.map(({ secretName }) =>
generateSecretBlindIndexWithSaltHelper({
secretName,
salt
})
)
).then((blindIndexes) =>
blindIndexes.reduce<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
})
.or(
deletedSecrets.map(({ secretName }) => ({
secretBlindIndex: secretBlindIndexes[secretName],
type: SECRET_SHARED
}))
)
.select({ secretBlindIndexes: 1 })
.lean()
.exec();
if (secretsToDelete.length !== deletedSecrets.length)
throw BadRequestError({ message: "Deleted secrets not found" });
commits.push(
...deletedSecrets.map((el) => ({
op: CommitType.DELETE as const,
secret: (
secretsToDelete.find(
(sec) => sec?.secretBlindIndex === secretBlindIndexes[el.secretName]
) as ISecret
)._id
}))
);
}
const secretApprovalRequest = new SecretApprovalRequest({
workspace: workspaceId,
environment,
folderId,
policy,
commits,
committer: commiterMembershipId
});
await secretApprovalRequest.save();
return secretApprovalRequest;
};
@@ -0,0 +1,28 @@
import { z } from "zod";
import { ApprovalStatus } from "../models/secretApprovalRequest";
export const getSecretApprovalRequests = z.object({
query: z.object({
workspaceId: z.string().trim(),
environment: z.string().trim().optional(),
committer: z.string().trim().optional(),
status: z.string().trim().optional(),
limit: z.coerce.number().default(20),
offset: z.coerce.number().default(0)
})
});
export const getSecretApprovalRequestDetails = z.object({
params: z.object({
id: z.string().trim()
})
});
export const updateSecretApprovalRequestStatus = z.object({
body: z.object({
status: z.enum([ApprovalStatus.APPROVED, ApprovalStatus.REJECTED])
}),
params: z.object({
id: z.string().trim()
})
});
+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";
@@ -0,0 +1,2 @@
export { useUpdateSecretApprovalRequestStatus } from "./mutation";
export { useGetSecretApprovalRequestDetails, useGetSecretApprovalRequests } from "./queries";
@@ -0,0 +1,20 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { apiRequest } from "@app/config/request";
import { secretApprovalRequestKeys } from "./queries";
import { TUpdateSecretApprovalRequestStatusDTO } from "./types";
export const useUpdateSecretApprovalRequestStatus = () => {
const queryClient = useQueryClient();
return useMutation<{}, {}, TUpdateSecretApprovalRequestStatusDTO>({
mutationFn: async ({ id, status }) => {
const { data } = await apiRequest.post(`/api/v1/secret-approval-requests/${id}`, { status });
return data;
},
onSuccess: (_, { id }) => {
queryClient.invalidateQueries(secretApprovalRequestKeys.detail({ id }));
}
});
};
@@ -0,0 +1,144 @@
import { useQuery, UseQueryOptions } from "@tanstack/react-query";
import {
decryptAssymmetric,
decryptSymmetric
} from "@app/components/utilities/cryptography/crypto";
import { apiRequest } from "@app/config/request";
import { UserWsKeyPair } from "../keys/types";
import { decryptSecrets } from "../secrets/queries";
import { DecryptedSecret } from "../secrets/types";
import {
TGetSecretApprovalRequestDetails,
TGetSecretApprovalRequestList,
TSecretApprovalRequest,
TSecretApprovalSecChange,
TSecretApprovalSecChangeData
} from "./types";
export const secretApprovalRequestKeys = {
list: ({ workspaceId, environment }: TGetSecretApprovalRequestList) =>
[{ workspaceId, environment }, "secret-approval-requests"] as const,
detail: ({ id }: Omit<TGetSecretApprovalRequestDetails, "decryptKey">) =>
[{ id }, "secret-approval-request-detail"] as const
};
export const decryptSecretApprovalSecret = (
encSecret: TSecretApprovalSecChangeData,
decryptFileKey: UserWsKeyPair
) => {
const PRIVATE_KEY = localStorage.getItem("PRIVATE_KEY") as string;
const key = decryptAssymmetric({
ciphertext: decryptFileKey.encryptedKey,
nonce: decryptFileKey.nonce,
publicKey: decryptFileKey.sender.publicKey,
privateKey: PRIVATE_KEY
});
const secretKey = decryptSymmetric({
ciphertext: encSecret.secretKeyCiphertext,
iv: encSecret.secretKeyIV,
tag: encSecret.secretKeyTag,
key
});
const secretValue = decryptSymmetric({
ciphertext: encSecret.secretValueCiphertext,
iv: encSecret.secretValueIV,
tag: encSecret.secretValueTag,
key
});
const secretComment = decryptSymmetric({
ciphertext: encSecret.secretCommentCiphertext,
iv: encSecret.secretCommentIV,
tag: encSecret.secretCommentTag,
key
});
return {
_id: encSecret._id,
version: encSecret.version,
secretKey,
secretValue,
secretComment,
tags: encSecret.tags
};
};
const fetchSecretApprovalRequestList = async ({
workspaceId,
environment
}: TGetSecretApprovalRequestList) => {
const { data } = await apiRequest.get<{ approvals: TSecretApprovalRequest[] }>(
"/api/v1/secret-approval-requests",
{
params: {
workspaceId,
environment
}
}
);
return data.approvals;
};
export const useGetSecretApprovalRequests = ({
workspaceId,
environment,
options = {}
}: TGetSecretApprovalRequestList & {
options?: Omit<
UseQueryOptions<
TSecretApprovalRequest[],
unknown,
TSecretApprovalRequest[],
ReturnType<typeof secretApprovalRequestKeys.list>
>,
"queryKey" | "queryFn"
>;
}) =>
useQuery({
queryKey: secretApprovalRequestKeys.list({ workspaceId, environment }),
queryFn: () => fetchSecretApprovalRequestList({ workspaceId, environment }),
enabled: Boolean(workspaceId) && (options?.enabled ?? true)
});
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(({ secret, op, newVersion }) => ({
op,
secret: secret ? decryptSecrets([secret], decryptKey)[0] : undefined,
newVersion: newVersion ? decryptSecretApprovalSecret(newVersion, decryptKey) : undefined
}))
}),
enabled: Boolean(id && decryptKey) && (options?.enabled ?? true)
});
@@ -0,0 +1,83 @@
import { UserWsKeyPair } from "../keys/types";
import { TSecretApprovalPolicy } from "../secretApproval/types";
import { EncryptedSecret } from "../secrets/types";
import { WsTag } from "../tags/types";
export enum ApprovalStatus {
PENDING = "pending",
APPROVED = "approved",
REJECTED = "rejected"
}
export enum CommitType {
DELETE = "delete",
UPDATE = "update",
CREATE = "create"
}
export type TSecretApprovalSecChangeData = {
_id: string;
version: number;
secretKeyCiphertext: string;
secretKeyIV: string;
secretKeyTag: string;
secretValueCiphertext: string;
secretValueIV: string;
secretValueTag: string;
secretCommentIV: string;
secretCommentTag: string;
secretCommentCiphertext: string;
skipMultilineEncoding?: boolean;
algorithm: "aes-256-gcm";
keyEncoding: "utf8" | "base64";
tags?: WsTag[];
};
export type TSecretApprovalSecChange = {
_id: string;
version: number;
secretKey: string;
secretValue: string;
secretComment: string;
tags?: string[];
};
export type TSecretApprovalRequest<
T extends unknown = TSecretApprovalSecChangeData,
J extends unknown = EncryptedSecret
> = {
_id: string;
committer: string;
reviewers: {
member: string;
status: ApprovalStatus;
}[];
workspace: string;
environment: string;
folderId: string;
hasMerged: boolean;
status: "open" | "close";
policy: TSecretApprovalPolicy;
commits: {
// if there is no secret means it was creation
secret?: J;
// if there is no new version its for Delete
newVersion?: T;
op: CommitType;
}[];
};
export type TGetSecretApprovalRequestList = {
workspaceId: string;
environment?: string;
};
export type TGetSecretApprovalRequestDetails = {
id: string;
decryptKey: UserWsKeyPair;
};
export type TUpdateSecretApprovalRequestStatusDTO = {
status: ApprovalStatus;
id: string;
};
+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,
+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,
@@ -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",
@@ -22,6 +23,9 @@ export const SecretApprovalPage = () => {
<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>
@@ -99,9 +99,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 && (
<Td colSpan={5}> <Tr>
<EmptyState title="No policies found" icon={faFileShield} /> <Td colSpan={5}>
</Td> <EmptyState title="No policies found" icon={faFileShield} />
</Td>
</Tr>
)} )}
{policies?.map((policy) => ( {policies?.map((policy) => (
<SecretApprovalPolicyRow <SecretApprovalPolicyRow
@@ -0,0 +1,97 @@
import { useState } from "react";
import { faCheck, faCodeBranch } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { AnimatePresence, motion } from "framer-motion";
import { useWorkspace } from "@app/context";
import { useGetSecretApprovalRequests, useGetWorkspaceUsers } from "@app/hooks/api";
import { 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);
const { data: secretApprovalRequests } = useGetSecretApprovalRequests({ workspaceId });
const { data: members } = useGetWorkspaceUsers(workspaceId);
const membersGroupById = members?.reduce<Record<string, TWorkspaceUser>>(
(prev, curr) => ({ ...prev, [curr._id]: curr }),
{}
);
const isSecretApprovalScreen = Boolean(selectedApproval);
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={() => setSelectedApproval(null)}
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 bg-mineshaft-800 text-gray-300"
>
<div className="p-4 px-8 flex items-center space-x-8">
<div>
<FontAwesomeIcon icon={faCodeBranch} className="mr-2" />
27 Open
</div>
<div className="text-gray-500">
<FontAwesomeIcon icon={faCheck} className="mr-2" />
27 Closed
</div>
</div>
<div className="flex flex-col border-t border-mineshaft-600">
{secretApprovalRequests?.map((secretApproval) => {
const { _id: reqId, commits, committer } = secretApproval;
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)}
</div>
<span className="text-xs text-gray-500">
Opened 2 hours ago by {membersGroupById?.[committer]?.user?.firstName}{" "}
{membersGroupById?.[committer]?.user?.lastName} (
{membersGroupById?.[committer]?.user?.email}) - Review required
</span>
</div>
);
})}
</div>
</motion.div>
)}
</AnimatePresence>
);
};
@@ -0,0 +1,356 @@
import { ReactNode } from "react";
import {
faArrowLeft,
faCheck,
faCheckCircle,
faCircle,
faClose,
faCodeBranch,
faXmarkCircle
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
Button,
ContentLoader,
IconButton,
SecretInput,
Table,
TableContainer,
Tag,
TBody,
Td,
Th,
THead,
Tooltip,
Tr
} from "@app/components/v2";
import {
useGetSecretApprovalRequestDetails,
useGetUserWsKey,
useUpdateSecretApprovalRequestStatus
} from "@app/hooks/api";
import { ApprovalStatus, CommitType, TWorkspaceUser } from "@app/hooks/api/types";
import { useNotificationContext } from "~/components/context/Notifications/NotificationProvider";
export const generateCommitText = (commits: { op: CommitType }[] = []) => {
const score: Record<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 { data: decryptFileKey } = useGetUserWsKey(workspaceId);
const {
data: secretApprovalRequestDetails,
isSuccess: isSecretApprovalRequestSuccess,
isLoading: isSecretApprovalRequestLoading
} = useGetSecretApprovalRequestDetails({
id: approvalRequestId,
decryptKey: decryptFileKey!
});
const {
mutateAsync: updateSecretApprovalRequestStatus,
isLoading: isUpdatingRequestStatus,
variables
} = useUpdateSecretApprovalRequestStatus();
const isApproving = variables?.status === ApprovalStatus.APPROVED && isUpdatingRequestStatus;
const isRejecting = variables?.status === ApprovalStatus.REJECTED && isUpdatingRequestStatus;
const reviewedMembers = secretApprovalRequestDetails?.reviewers?.reduce<
Record<string, ApprovalStatus>
>(
(prev, curr) => ({
...prev,
[curr.member]: curr.status
}),
{}
);
const handleSecretApprovalStatusUpdate = async (status: ApprovalStatus) => {
try {
await updateSecretApprovalRequestStatus({
id: approvalRequestId,
status
});
} catch (err) {
console.log(err);
createNotification({
type: "error",
text: "Failed to update the request status"
});
}
};
if (isSecretApprovalRequestLoading) {
<div>
<ContentLoader />
</div>;
}
if (!isSecretApprovalRequestSuccess) return <div>Failed</div>;
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">
{committer?.user?.firstName}
{committer?.user?.lastName} ({committer?.user?.email}) wants to change{" "}
{secretApprovalRequestDetails.commits.length} secret values in{" "}
<span className="text-blue-300 bg-blue-600/60 px-1">
{secretApprovalRequestDetails.environment}
</span>
</div>
</div>
<Button
size="xs"
leftIcon={<FontAwesomeIcon icon={faCheck} />}
onClick={() => handleSecretApprovalStatusUpdate(ApprovalStatus.APPROVED)}
isLoading={isApproving}
isDisabled={isApproving}
>
Approve
</Button>
<Button
size="xs"
variant="outline_bg"
leftIcon={<FontAwesomeIcon icon={faClose} />}
onClick={() => handleSecretApprovalStatusUpdate(ApprovalStatus.REJECTED)}
isLoading={isRejecting}
isDisabled={isRejecting}
>
Reject
</Button>
</div>
<div className="flex flex-col space-y-4">
{secretApprovalRequestDetails.commits.map(({ op, secret, newVersion }, index) => (
<div key={`commit-change-secret-${index + 1}`}>
<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>{secret?.key}</Td>
<Td>
<SecretInput isReadOnly value={secret?.value} />
</Td>
<Td>{secret?.comment}</Td>
<Td>
{secret?.tags?.map(({ name, _id: tagId, tagColor }) => (
<Tag
className="flex items-center space-x-2 w-min"
key={`${secret._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 : secret?.key}</Td>
<Td>
<SecretInput
isReadOnly
value={
op === CommitType.CREATE ? newVersion?.secretValue : secret?.value
}
/>
</Td>
<Td>
{op === CommitType.CREATE ? newVersion?.secretComment : secret?.comment}
</Td>
<Td>
{(op === CommitType.CREATE ? newVersion?.tags : secret?.tags)?.map(
({ name, _id: tagId, tagColor }) => (
<Tag
className="flex items-center space-x-2 w-min"
key={`${
op === CommitType.CREATE ? newVersion?._id : secret?._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>
))}
</div>
<div className="flex items-center px-4 py-6 rounded-lg space-x-6 bg-mineshaft-800 mt-8">
<Button leftIcon={<FontAwesomeIcon icon={faCheck} />}>Merge</Button>
<Button variant="outline_bg" leftIcon={<FontAwesomeIcon icon={faClose} />}>
Close request
</Button>
</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";