create approval

This commit is contained in:
Maidul Islam
2023-02-22 00:01:12 -05:00
parent 0e17c9a6db
commit eb055e8b16
12 changed files with 372 additions and 35 deletions

View File

@ -39,7 +39,8 @@ import {
password as v1PasswordRouter,
stripe as v1StripeRouter,
integration as v1IntegrationRouter,
integrationAuth as v1IntegrationAuthRouter
integrationAuth as v1IntegrationAuthRouter,
secretApprovalRequest as v1SecretApprovalRequest
} from './routes/v1';
import {
signup as v2SignupRouter,
@ -59,7 +60,7 @@ import { healthCheck } from './routes/status';
import { getLogger } from './utils/logger';
import { RouteNotFoundError } from './utils/errors';
import { requestErrorHandler } from './middleware/requestErrorHandler';
import { handleMongoInvalidDataError, requestErrorHandler } from './middleware/requestErrorHandler';
// patch async route params to handle Promise Rejections
patchRouterParam();
@ -110,6 +111,7 @@ app.use('/api/v1/password', v1PasswordRouter);
app.use('/api/v1/stripe', v1StripeRouter);
app.use('/api/v1/integration', v1IntegrationRouter);
app.use('/api/v1/integration-auth', v1IntegrationAuthRouter);
app.use('/api/v1/secrets-approval-request', v1SecretApprovalRequest)
// v2 routes
app.use('/api/v2/signup', v2SignupRouter);
@ -136,6 +138,9 @@ app.use((req, res, next) => {
next(RouteNotFoundError({ message: `The requested source '(${req.method})${req.url}' was not found` }))
})
// handle mongo validation errors
app.use(handleMongoInvalidDataError);
//* Error Handling Middleware (must be after all routing logic)
app.use(requestErrorHandler)

View File

@ -14,6 +14,7 @@ import * as stripeController from './stripeController';
import * as userActionController from './userActionController';
import * as userController from './userController';
import * as workspaceController from './workspaceController';
import * as secretApprovalController from './secretApprovalController';
export {
authController,
@ -31,5 +32,6 @@ export {
stripeController,
userActionController,
userController,
workspaceController
workspaceController,
secretApprovalController
};

View File

@ -0,0 +1,104 @@
import { Request, Response } from 'express';
import SecretApprovalRequest, { ApprovalStatus, IRequestedChange } from '../../models/secretApprovalRequest';
import { Builder, IBuilder } from "builder-pattern"
import { validateSecrets } from '../../helpers/secret';
import _ from 'lodash';
import { SECRET_PERSONAL, SECRET_SHARED } from '../../variables';
import { BadRequestError, ResourceNotFound } from '../../utils/errors';
import { Workspace } from '../../models';
export const createApprovalRequest = async (req: Request, res: Response) => {
const { workspaceId, environment, requestedChanges } = req.body;
// validate workspace
const workspaceFromDB = await Workspace.findById(workspaceId)
if (!workspaceFromDB) {
throw ResourceNotFound()
}
const environmentBelongsToWorkspace = _.some(workspaceFromDB.environments, { slug: environment })
if (!environmentBelongsToWorkspace) {
throw ResourceNotFound()
}
// check for secret duplicates
const hasSecretIdDuplicates = requestedChanges.length !== _.uniqBy(requestedChanges, 'modifiedSecretId').length;
if (hasSecretIdDuplicates) {
throw BadRequestError({ message: "Request cannot contain duplicate secrets" })
}
// ensure the workspace has approvers set
if (!workspaceFromDB.approvers.length) {
throw BadRequestError({ message: "There are no designated approvers for this project, you must set approvers first before making a request" })
}
const approverIds = _.map(workspaceFromDB.approvers, "userId")
const listOfSecretIdsToModify = _.map(requestedChanges, "modifiedSecretId")
// ensure the secrets user is requesting to modify are the ones they have access to
await validateSecrets({
userId: req.user._id.toString(),
secretIds: listOfSecretIdsToModify
});
const sanitizedRequestedChangesList: IRequestedChange[] = []
requestedChanges.forEach((requestedChange: IRequestedChange) => {
const modifiedSecret = requestedChange.modifiedSecret
if (!modifiedSecret.type || !(modifiedSecret.type === SECRET_PERSONAL || modifiedSecret.type === SECRET_SHARED) || !modifiedSecret.secretKeyCiphertext || !modifiedSecret.secretKeyIV || !modifiedSecret.secretKeyTag || (typeof modifiedSecret.secretValueCiphertext !== 'string') || !modifiedSecret.secretValueIV || !modifiedSecret.secretValueTag) {
throw BadRequestError({ message: "One or more required fields are missing from your modified secret" })
}
sanitizedRequestedChangesList.push(Builder<IRequestedChange>()
.modifiedSecretId(requestedChange.modifiedSecretId)
.modifiedSecret(requestedChange.modifiedSecret)
.type(requestedChange.type).build())
});
const filter = {
workspace: workspaceId,
requestedByUserId: req.user._id.toString(),
environment: environment,
status: ApprovalStatus.PENDING.toString()
};
const update = {
requestedChanges: sanitizedRequestedChangesList,
approvers: approverIds
};
const options = {
new: true,
upsert: true
};
const request = await SecretApprovalRequest.findOneAndUpdate(filter, update, options)
return res.status(200).send(request);
};
export const cancelApprovalRequest = async (req: Request, res: Response) => {
return res.status(200).send({
user: req.user
});
};
export const updateApprovalRequest = async (req: Request, res: Response) => {
return res.status(200).send({
user: req.user
});
};
export const addApproverToApprovalRequest = async (req: Request, res: Response) => {
return res.status(200).send({
user: req.user
});
};
export const removeApproverFromApprovalRequest = async (req: Request, res: Response) => {
return res.status(200).send({
user: req.user
});
};

View File

@ -16,6 +16,8 @@ import {
} from "../../helpers/workspace";
import { addMemberships } from "../../helpers/membership";
import { ADMIN } from "../../variables";
import { BadRequestError, ResourceNotFound, UnauthorizedRequestError } from "../../utils/errors";
import _ from "lodash";
/**
* Return public keys of members of workspace with id [workspaceId]
@ -303,6 +305,93 @@ export const getWorkspaceIntegrationAuthorizations = async (
});
};
export const addApproverForWorkspaceAndEnvironment = async (
req: Request,
res: Response
) => {
const { workspaceId } = req.params;
const { approvers } = req.body
const workspaceFromDB = await Workspace.findById(workspaceId)
if (!workspaceFromDB) {
throw ResourceNotFound()
}
const approverIds = _.map(approvers, "userId")
const workspaceEnvironments = _.map(workspaceFromDB.environments, 'slug');
const approversEnvironments = _.map(approvers, "environment")
const environmentsDifference = _.difference(approversEnvironments, workspaceEnvironments);
// validate environments
if (environmentsDifference.length != 0) {
const err = `Invalid environments set for approver(s) [environmentsDifference=${environmentsDifference}]`
throw BadRequestError({ message: err })
}
// validate approvers membership
const membershipValidation = await Membership.find({
workspace: workspaceId,
user: { $in: approverIds }
})
if (!membershipValidation) {
throw ResourceNotFound()
}
if (membershipValidation.length != approverIds.length) {
throw UnauthorizedRequestError({ message: "Approvers must be apart of the workspace they are being added to" })
}
const updatedWorkspace = await Workspace.findByIdAndUpdate(workspaceId, { $addToSet: { approvers: { $each: approvers } } }, { new: true })
return res.json(updatedWorkspace)
};
export const removeApproverForWorkspaceAndEnvironment = async (
req: Request,
res: Response
) => {
const { workspaceId } = req.params;
const { approvers } = req.body
const workspaceFromDB = await Workspace.findById(workspaceId)
if (!workspaceFromDB) {
throw ResourceNotFound()
}
const approverIds = _.map(approvers, "userId")
const workspaceEnvironments = _.map(workspaceFromDB.environments, 'slug');
const approversEnvironments = _.map(approvers, "environment")
const environmentsDifference = _.difference(approversEnvironments, workspaceEnvironments);
// validate environments
if (environmentsDifference.length != 0) {
const err = `Invalid environments set for approver(s) [environmentsDifference=${environmentsDifference}]`
throw BadRequestError({ message: err })
}
// validate approvers membership
const membershipValidation = await Membership.find({
workspace: workspaceId,
user: { $in: approverIds }
})
if (!membershipValidation) {
throw ResourceNotFound()
}
if (membershipValidation.length != approverIds.length) {
throw UnauthorizedRequestError({ message: "Approvers must be apart of the workspace they are being added to" })
}
const updatedWorkspace = await Workspace.updateOne({ _id: workspaceId }, { $pullAll: { approvers: approvers } }, { new: true })
return res.json(updatedWorkspace)
};
/**
* Return service service tokens for workspace [workspaceId] belonging to user
* @param req

View File

@ -1,12 +1,12 @@
import { ErrorRequestHandler } from "express";
import * as Sentry from '@sentry/node';
import { InternalServerError, UnauthorizedRequestError } from "../utils/errors";
import { InternalServerError, UnauthorizedRequestError, UnprocessableEntityError } from "../utils/errors";
import { getLogger } from "../utils/logger";
import RequestError, { LogLevel } from "../utils/requestError";
import { NODE_ENV } from "../config";
import { TokenExpiredError } from 'jsonwebtoken';
import mongoose from "mongoose";
export const requestErrorHandler: ErrorRequestHandler = (error: RequestError | Error, req, res, next) => {
if (res.headersSent) return next();
@ -34,4 +34,17 @@ export const requestErrorHandler: ErrorRequestHandler = (error: RequestError | E
res.status((<RequestError>error).statusCode).json((<RequestError>error).format(req))
next()
}
export const handleMongoInvalidDataError = (err: any, req: any, res: any, next: any) => {
if (err instanceof mongoose.Error.ValidationError) {
const errors: any = {};
for (const field in err.errors) {
errors[field] = err.errors[field].message;
}
throw UnprocessableEntityError({ message: JSON.stringify(errors) })
} else {
next(err);
}
}

View File

@ -26,7 +26,7 @@ export interface ISecret {
tags?: string[];
}
const secretSchema = new Schema<ISecret>(
export const secretSchema = new Schema<ISecret>(
{
version: {
type: Number,

View File

@ -1,14 +1,24 @@
import mongoose, { Schema, model } from 'mongoose';
import Secret, { ISecret } from './secret';
import Secret, { ISecret, secretSchema } from './secret';
export interface IRequestedChange {
userId: mongoose.Types.ObjectId;
status: ApprovalStatus;
modifiedSecret: ISecret,
modifiedSecretId: mongoose.Types.ObjectId,
type: string
isApproved: boolean
}
interface ISecretApprovalRequest {
secret: mongoose.Types.ObjectId;
requestedChanges: ISecret;
requestedBy: mongoose.Types.ObjectId;
environment: string;
workspace: mongoose.Types.ObjectId;
requestedChanges: IRequestedChange;
requestedByUserId: mongoose.Types.ObjectId;
approvers: IApprover[];
status: ApprovalStatus;
timestamp: Date;
requestType: RequestType;
requestType: ChangeType;
requestId: string;
}
@ -23,7 +33,7 @@ export enum ApprovalStatus {
REJECTED = 'rejected'
}
export enum RequestType {
export enum ChangeType {
UPDATE = 'update',
DELETE = 'delete',
CREATE = 'create'
@ -33,7 +43,7 @@ const approverSchema = new mongoose.Schema({
user: {
type: mongoose.Schema.Types.ObjectId,
ref: 'User',
required: true
required: false
},
status: {
type: String,
@ -44,33 +54,44 @@ const approverSchema = new mongoose.Schema({
const secretApprovalRequestSchema = new Schema<ISecretApprovalRequest>(
{
secret: {
type: mongoose.Schema.Types.ObjectId,
environment: {
type: String, // The secret changes were requested for
ref: 'Secret'
},
requestedChanges: Secret,
requestedBy: {
workspace: {
type: mongoose.Schema.Types.ObjectId, // workspace id of the secret
ref: 'Workspace'
},
requestedChanges: [
{
modifiedSecret: secretSchema,
modifiedSecretId: {
type: mongoose.Schema.Types.ObjectId,
ref: 'Secret'
},
type: {
type: String,
enum: ChangeType,
required: true
},
isApproved: {
type: Boolean,
default: false,
}
}
], // the changes that the requested user wants to make to the existing secret
requestedByUserId: {
type: mongoose.Schema.Types.ObjectId,
ref: 'User'
},
approvers: [approverSchema],
approvers: [approverSchema], // the approvers who need to approve in order to merge this change
status: {
type: String,
enum: ApprovalStatus,
default: ApprovalStatus.PENDING
},
timestamp: {
type: Date,
default: Date.now
},
requestType: {
type: String,
enum: RequestType,
required: true
default: ApprovalStatus.PENDING // the overall status of the approval
},
requestId: {
type: String,
required: false
}
},
{
@ -78,6 +99,6 @@ const secretApprovalRequestSchema = new Schema<ISecretApprovalRequest>(
}
);
const SecretApprovalRequest = model<ISecretApprovalRequest>('SecretApprovalRequest', secretApprovalRequestSchema);
const SecretApprovalRequest = model<ISecretApprovalRequest>('secret_approval_request', secretApprovalRequestSchema);
export default SecretApprovalRequest;

View File

@ -1,9 +1,16 @@
import { Schema, model, Types } from 'mongoose';
import mongoose, { Schema, model, Types } from 'mongoose';
export interface DesignatedApprovers {
environment: string,
approvers: [mongoose.Schema.Types.ObjectId]
}
export interface IWorkspace {
_id: Types.ObjectId;
name: string;
organization: Types.ObjectId;
approvers: [DesignatedApprovers];
environments: Array<{
name: string;
slug: string;
@ -20,6 +27,17 @@ const workspaceSchema = new Schema<IWorkspace>({
type: Boolean,
default: true,
},
approvers: [
{
userId: {
type: Schema.Types.ObjectId,
ref: 'User',
},
environment: {
type: String
}
}
],
organization: {
type: Schema.Types.ObjectId,
ref: 'Organization',
@ -53,6 +71,8 @@ const workspaceSchema = new Schema<IWorkspace>({
},
});
workspaceSchema.index({ 'approvers': 1 }, { unique: true });
const Workspace = model<IWorkspace>('Workspace', workspaceSchema);
export default Workspace;

View File

@ -15,6 +15,7 @@ import password from './password';
import stripe from './stripe';
import integration from './integration';
import integrationAuth from './integrationAuth';
import secretApprovalRequest from './secretApprovalsRequest'
export {
signup,
@ -33,5 +34,6 @@ export {
password,
stripe,
integration,
integrationAuth
integrationAuth,
secretApprovalRequest
};

View File

@ -0,0 +1,35 @@
import express from 'express';
const router = express.Router();
import { requireAuth, validateRequest } from '../../middleware';
import { secretApprovalController } from '../../controllers/v1';
import { body, param } from 'express-validator';
router.post(
'/',
requireAuth({
acceptedAuthModes: ['jwt']
}),
body('workspaceId').exists(),
body('environment').exists(),
body('requestedChanges').isArray(),
validateRequest,
secretApprovalController.createApprovalRequest
);
// router.post(
// '/add-approver',
// requireAuth({
// acceptedAuthModes: ['jwt']
// }),
// secretApprovalController.createApprovalRequest
// );
// router.post(
// '/remove_approver',
// requireAuth({
// acceptedAuthModes: ['jwt']
// }),
// secretApprovalController.createApprovalRequest
// );
export default router;

View File

@ -36,10 +36,10 @@ router.get(
);
router.get(
'/',
'/',
requireAuth({
acceptedAuthModes: ['jwt']
}),
}),
workspaceController.getWorkspaces
);
@ -134,6 +134,34 @@ router.get(
workspaceController.getWorkspaceIntegrationAuthorizations
);
router.post(
'/:workspaceId/approvers',
requireAuth({
acceptedAuthModes: ['jwt']
}),
requireWorkspaceAuth({
acceptedRoles: [ADMIN]
}),
param('workspaceId').exists().trim(),
body("approvers").isArray(),
validateRequest,
workspaceController.addApproverForWorkspaceAndEnvironment
);
router.delete(
'/:workspaceId/approvers',
requireAuth({
acceptedAuthModes: ['jwt']
}),
requireWorkspaceAuth({
acceptedRoles: [ADMIN]
}),
param('workspaceId').exists().trim(),
body("approvers").isArray(),
validateRequest,
workspaceController.removeApproverForWorkspaceAndEnvironment
);
router.get(
'/:workspaceId/service-tokens', // deprecate
requireAuth({

View File

@ -19,6 +19,15 @@ export const MethodNotAllowedError = (error?: Partial<RequestErrorContext>) => n
stack: error?.stack
});
export const UnprocessableEntityError = (error?: Partial<RequestErrorContext>) => new RequestError({
logLevel: error?.logLevel ?? LogLevel.INFO,
statusCode: error?.statusCode ?? 422,
type: error?.type ?? 'unprocessable_entity',
message: error?.message ?? 'The server understands the content of the request, but it was unable to process it because it contains invalid data',
context: error?.context,
stack: error?.stack
});
export const UnauthorizedRequestError = (error?: Partial<RequestErrorContext>) => new RequestError({
logLevel: error?.logLevel ?? LogLevel.INFO,
statusCode: error?.statusCode ?? 401,
@ -27,7 +36,7 @@ export const UnauthorizedRequestError = (error?: Partial<RequestErrorContext>) =
context: error?.context,
stack: error?.stack
});
export const ForbiddenRequestError = (error?: Partial<RequestErrorContext>) => new RequestError({
logLevel: error?.logLevel ?? LogLevel.INFO,
statusCode: error?.statusCode ?? 403,
@ -46,6 +55,15 @@ export const BadRequestError = (error?: Partial<RequestErrorContext>) => new Req
stack: error?.stack
});
export const ResourceNotFound = (error?: Partial<RequestErrorContext>) => new RequestError({
logLevel: error?.logLevel ?? LogLevel.INFO,
statusCode: error?.statusCode ?? 404,
type: error?.type ?? 'resource_not_found',
message: error?.message ?? 'The requested resource was not found',
context: error?.context,
stack: error?.stack
});
export const InternalServerError = (error?: Partial<RequestErrorContext>) => new RequestError({
logLevel: error?.logLevel ?? LogLevel.ERROR,
statusCode: error?.statusCode ?? 500,