mirror of
https://github.com/awatertrevi/infisical.git
synced 2026-10-16 03:31:27 +00:00
feat(secret-approval): moved approval code to ee
This commit is contained in:
@@ -8,6 +8,8 @@ import * as actionController from "./actionController";
|
||||
import * as membershipController from "./membershipController";
|
||||
import * as cloudProductsController from "./cloudProductsController";
|
||||
import * as roleController from "./roleController";
|
||||
import * as secretApprovalPolicyController from "./secretApprovalPolicyController";
|
||||
import * as secretApprovalRequestController from "./secretApprovalRequestsController";
|
||||
|
||||
export {
|
||||
secretController,
|
||||
@@ -19,5 +21,7 @@ export {
|
||||
actionController,
|
||||
membershipController,
|
||||
cloudProductsController,
|
||||
roleController
|
||||
roleController,
|
||||
secretApprovalPolicyController,
|
||||
secretApprovalRequestController
|
||||
};
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
import { ForbiddenError, subject } from "@casl/ability";
|
||||
import { Request, Response } from "express";
|
||||
import { nanoid } from "nanoid";
|
||||
import {
|
||||
ProjectPermissionActions,
|
||||
ProjectPermissionSub,
|
||||
getUserProjectPermissions
|
||||
} from "../../services/ProjectRoleService";
|
||||
import { validateRequest } from "../../../helpers/validation";
|
||||
import { SecretApprovalPolicy } from "../../models/secretApprovalPolicy";
|
||||
import { getSecretPolicyOfBoard } from "../../services/SecretApprovalService";
|
||||
import { BadRequestError } from "../../../utils/errors";
|
||||
import * as reqValidator from "../../validation/secretApproval";
|
||||
|
||||
const ERR_SECRET_APPROVAL_NOT_FOUND = BadRequestError({ message: "secret approval not found" });
|
||||
|
||||
export const createSecretApprovalPolicy = async (req: Request, res: Response) => {
|
||||
const {
|
||||
body: { approvals, secretPath, approvers, environment, workspaceId, name }
|
||||
} = await validateRequest(reqValidator.CreateSecretApprovalRule, req);
|
||||
|
||||
const { permission } = await getUserProjectPermissions(req.user._id, workspaceId);
|
||||
ForbiddenError.from(permission).throwUnlessCan(
|
||||
ProjectPermissionActions.Create,
|
||||
ProjectPermissionSub.SecretApproval
|
||||
);
|
||||
|
||||
const secretApproval = new SecretApprovalPolicy({
|
||||
workspace: workspaceId,
|
||||
name: name ?? `${environment}-${nanoid(3)}`,
|
||||
secretPath,
|
||||
environment,
|
||||
approvals,
|
||||
approvers
|
||||
});
|
||||
await secretApproval.save();
|
||||
|
||||
return res.send({
|
||||
approval: secretApproval
|
||||
});
|
||||
};
|
||||
|
||||
export const updateSecretApprovalPolicy = async (req: Request, res: Response) => {
|
||||
const {
|
||||
body: { approvals, approvers, secretPath, name },
|
||||
params: { id }
|
||||
} = await validateRequest(reqValidator.UpdateSecretApprovalRule, req);
|
||||
|
||||
const secretApproval = await SecretApprovalPolicy.findById(id);
|
||||
if (!secretApproval) throw ERR_SECRET_APPROVAL_NOT_FOUND;
|
||||
|
||||
const { permission } = await getUserProjectPermissions(
|
||||
req.user._id,
|
||||
secretApproval.workspace.toString()
|
||||
);
|
||||
ForbiddenError.from(permission).throwUnlessCan(
|
||||
ProjectPermissionActions.Edit,
|
||||
ProjectPermissionSub.SecretApproval
|
||||
);
|
||||
|
||||
const updatedDoc = await SecretApprovalPolicy.findByIdAndUpdate(id, {
|
||||
approvals,
|
||||
approvers,
|
||||
name: (name || secretApproval?.name) ?? `${secretApproval.environment}-${nanoid(3)}`,
|
||||
...(secretPath === null ? { $unset: { secretPath: 1 } } : { secretPath })
|
||||
});
|
||||
|
||||
return res.send({
|
||||
approval: updatedDoc
|
||||
});
|
||||
};
|
||||
|
||||
export const deleteSecretApprovalPolicy = async (req: Request, res: Response) => {
|
||||
const {
|
||||
params: { id }
|
||||
} = await validateRequest(reqValidator.DeleteSecretApprovalRule, req);
|
||||
|
||||
const secretApproval = await SecretApprovalPolicy.findById(id);
|
||||
if (!secretApproval) throw ERR_SECRET_APPROVAL_NOT_FOUND;
|
||||
|
||||
const { permission } = await getUserProjectPermissions(
|
||||
req.user._id,
|
||||
secretApproval.workspace.toString()
|
||||
);
|
||||
ForbiddenError.from(permission).throwUnlessCan(
|
||||
ProjectPermissionActions.Delete,
|
||||
ProjectPermissionSub.SecretApproval
|
||||
);
|
||||
|
||||
const deletedDoc = await SecretApprovalPolicy.findByIdAndDelete(id);
|
||||
|
||||
return res.send({
|
||||
approval: deletedDoc
|
||||
});
|
||||
};
|
||||
|
||||
export const getSecretApprovalPolicy = async (req: Request, res: Response) => {
|
||||
const {
|
||||
query: { workspaceId }
|
||||
} = await validateRequest(reqValidator.GetSecretApprovalRuleList, req);
|
||||
|
||||
const { permission } = await getUserProjectPermissions(req.user._id, workspaceId);
|
||||
ForbiddenError.from(permission).throwUnlessCan(
|
||||
ProjectPermissionActions.Read,
|
||||
ProjectPermissionSub.SecretApproval
|
||||
);
|
||||
|
||||
const doc = await SecretApprovalPolicy.find({ workspace: workspaceId });
|
||||
|
||||
return res.send({
|
||||
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 });
|
||||
};
|
||||
@@ -0,0 +1,51 @@
|
||||
import { Schema, Types, model } from "mongoose";
|
||||
|
||||
export interface ISecretApprovalPolicy {
|
||||
_id: Types.ObjectId;
|
||||
workspace: Types.ObjectId;
|
||||
name: string;
|
||||
environment: string;
|
||||
secretPath?: string;
|
||||
approvers: Types.ObjectId[];
|
||||
approvals: number;
|
||||
}
|
||||
|
||||
const secretApprovalPolicySchema = new Schema<ISecretApprovalPolicy>(
|
||||
{
|
||||
workspace: {
|
||||
type: Schema.Types.ObjectId,
|
||||
ref: "Workspace",
|
||||
required: true
|
||||
},
|
||||
approvers: [
|
||||
{
|
||||
// user associated with the personal secret
|
||||
type: Schema.Types.ObjectId,
|
||||
ref: "Membership"
|
||||
}
|
||||
],
|
||||
name: {
|
||||
type: String
|
||||
},
|
||||
environment: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
secretPath: {
|
||||
type: String,
|
||||
required: false
|
||||
},
|
||||
approvals: {
|
||||
type: Number,
|
||||
default: 1
|
||||
}
|
||||
},
|
||||
{
|
||||
timestamps: true
|
||||
}
|
||||
);
|
||||
|
||||
export const SecretApprovalPolicy = model<ISecretApprovalPolicy>(
|
||||
"SecretApprovalPolicy",
|
||||
secretApprovalPolicySchema
|
||||
);
|
||||
@@ -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
|
||||
);
|
||||
@@ -8,6 +8,8 @@ import action from "./action";
|
||||
import cloudProducts from "./cloudProducts";
|
||||
import secretScanning from "./secretScanning";
|
||||
import roles from "./role";
|
||||
import secretApprovalPolicy from "./secretApprovalPolicy";
|
||||
import secretApprovalRequest from "./secretApprovalRequest";
|
||||
|
||||
export {
|
||||
secret,
|
||||
@@ -19,5 +21,7 @@ export {
|
||||
action,
|
||||
cloudProducts,
|
||||
secretScanning,
|
||||
roles
|
||||
roles,
|
||||
secretApprovalPolicy,
|
||||
secretApprovalRequest
|
||||
};
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import express from "express";
|
||||
const router = express.Router();
|
||||
import { requireAuth } from "../../../middleware";
|
||||
import { secretApprovalPolicyController } from "../../controllers/v1";
|
||||
import { AuthMode } from "../../../variables";
|
||||
|
||||
router.get(
|
||||
"/",
|
||||
requireAuth({
|
||||
acceptedAuthModes: [AuthMode.JWT]
|
||||
}),
|
||||
secretApprovalPolicyController.getSecretApprovalPolicy
|
||||
);
|
||||
|
||||
router.get(
|
||||
"/board",
|
||||
requireAuth({
|
||||
acceptedAuthModes: [AuthMode.JWT]
|
||||
}),
|
||||
secretApprovalPolicyController.getSecretApprovalPolicyOfBoard
|
||||
);
|
||||
|
||||
router.post(
|
||||
"/",
|
||||
requireAuth({
|
||||
acceptedAuthModes: [AuthMode.JWT]
|
||||
}),
|
||||
secretApprovalPolicyController.createSecretApprovalPolicy
|
||||
);
|
||||
|
||||
router.patch(
|
||||
"/:id",
|
||||
requireAuth({
|
||||
acceptedAuthModes: [AuthMode.JWT]
|
||||
}),
|
||||
secretApprovalPolicyController.updateSecretApprovalPolicy
|
||||
);
|
||||
|
||||
router.delete(
|
||||
"/:id",
|
||||
requireAuth({
|
||||
acceptedAuthModes: [AuthMode.JWT]
|
||||
}),
|
||||
secretApprovalPolicyController.deleteSecretApprovalPolicy
|
||||
);
|
||||
|
||||
export default router;
|
||||
@@ -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;
|
||||
@@ -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"])
|
||||
})
|
||||
});
|
||||
Reference in New Issue
Block a user