From 3ad3e19bcf202de57c812555a902204f02aa6a0f Mon Sep 17 00:00:00 2001 From: akhilmhdh Date: Wed, 28 Dec 2022 23:42:17 +0530 Subject: [PATCH] feat(#31): implemented api for environment crud operations --- backend/src/app.ts | 2 + .../v1/integrationAuthController.ts | 8 +- .../src/controllers/v1/secretController.ts | 10 +- .../controllers/v1/serviceTokenController.ts | 4 +- .../controllers/v2/environmentController.ts | 167 ++++++++++++++++++ backend/src/controllers/v2/index.ts | 4 +- .../src/controllers/v2/workspaceController.ts | 9 +- backend/src/helpers/integration.ts | 8 +- backend/src/models/integration.ts | 5 - backend/src/models/secret.ts | 5 - backend/src/models/serviceToken.ts | 4 - backend/src/models/workspace.ts | 32 +++- backend/src/routes/v2/environment.ts | 54 ++++++ backend/src/routes/v2/index.ts | 6 +- backend/src/services/IntegrationService.ts | 12 +- 15 files changed, 293 insertions(+), 37 deletions(-) create mode 100644 backend/src/controllers/v2/environmentController.ts create mode 100644 backend/src/routes/v2/environment.ts diff --git a/backend/src/app.ts b/backend/src/app.ts index aa7ac9b28..571b84cd4 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -46,6 +46,7 @@ import { workspace as v2WorkspaceRouter, serviceTokenData as v2ServiceTokenDataRouter, apiKeyData as v2APIKeyDataRouter, + environment as v2EnvironmentRouter, } from './routes/v2'; import { healthCheck } from './routes/status'; @@ -108,6 +109,7 @@ app.use('/api/v2/secret', v2SecretRouter); // stop supporting, TODO: revise app.use('/api/v2/secrets', v2SecretsRouter); app.use('/api/v2/service-token', v2ServiceTokenDataRouter); // TODO: turn into plural route app.use('/api/v2/api-key-data', v2APIKeyDataRouter); +app.use('/api/v2/environments', v2EnvironmentRouter); // api docs app.use('/api-docs', swaggerUi.serve, swaggerUi.setup(swaggerFile)) diff --git a/backend/src/controllers/v1/integrationAuthController.ts b/backend/src/controllers/v1/integrationAuthController.ts index 95d0066ae..b2f93a8c2 100644 --- a/backend/src/controllers/v1/integrationAuthController.ts +++ b/backend/src/controllers/v1/integrationAuthController.ts @@ -31,11 +31,17 @@ export const oAuthExchange = async ( if (!INTEGRATION_SET.has(integration)) throw new Error('Failed to validate integration'); + + const environments = req.membership.workspace?.environments || []; + if(environments.length === 0){ + throw new Error("Failed to get environments") + } await IntegrationService.handleOAuthExchange({ workspaceId, integration, - code + code, + environment: environments[0].slug, }); } catch (err) { Sentry.setUser(null); diff --git a/backend/src/controllers/v1/secretController.ts b/backend/src/controllers/v1/secretController.ts index 1b756ecc7..c76e5e883 100644 --- a/backend/src/controllers/v1/secretController.ts +++ b/backend/src/controllers/v1/secretController.ts @@ -9,7 +9,6 @@ import { import { pushKeys } from '../../helpers/key'; import { eventPushSecrets } from '../../events'; import { EventService } from '../../services'; -import { ENV_SET } from '../../variables'; import { postHogClient } from '../../services'; interface PushSecret { @@ -44,7 +43,8 @@ export const pushSecrets = async (req: Request, res: Response) => { const { workspaceId } = req.params; // validate environment - if (!ENV_SET.has(environment)) { + const workspaceEnvs = req.membership.workspace.environments; + if (!workspaceEnvs.find(({ slug }: { slug: string }) => slug === environment)) { throw new Error('Failed to validate environment'); } @@ -116,7 +116,8 @@ export const pullSecrets = async (req: Request, res: Response) => { const { workspaceId } = req.params; // validate environment - if (!ENV_SET.has(environment)) { + const workspaceEnvs = req.membership.workspace.environments; + if (!workspaceEnvs.find(({ slug }: { slug: string }) => slug === environment)) { throw new Error('Failed to validate environment'); } @@ -183,7 +184,8 @@ export const pullSecretsServiceToken = async (req: Request, res: Response) => { const { workspaceId } = req.params; // validate environment - if (!ENV_SET.has(environment)) { + const workspaceEnvs = req.membership.workspace.environments; + if (!workspaceEnvs.find(({ slug }: { slug: string }) => slug === environment)) { throw new Error('Failed to validate environment'); } diff --git a/backend/src/controllers/v1/serviceTokenController.ts b/backend/src/controllers/v1/serviceTokenController.ts index 244a58783..3fafb9043 100644 --- a/backend/src/controllers/v1/serviceTokenController.ts +++ b/backend/src/controllers/v1/serviceTokenController.ts @@ -1,7 +1,6 @@ import { Request, Response } from 'express'; import { ServiceToken } from '../../models'; import { createToken } from '../../helpers/auth'; -import { ENV_SET } from '../../variables'; import { JWT_SERVICE_SECRET } from '../../config'; /** @@ -36,7 +35,8 @@ export const createServiceToken = async (req: Request, res: Response) => { } = req.body; // validate environment - if (!ENV_SET.has(environment)) { + const workspaceEnvs = req.membership.workspace.environments; + if (!workspaceEnvs.find(({ slug }: { slug: string }) => slug === environment)) { throw new Error('Failed to validate environment'); } diff --git a/backend/src/controllers/v2/environmentController.ts b/backend/src/controllers/v2/environmentController.ts new file mode 100644 index 000000000..77c4fcb59 --- /dev/null +++ b/backend/src/controllers/v2/environmentController.ts @@ -0,0 +1,167 @@ +import { Request, Response } from 'express'; +import * as Sentry from '@sentry/node'; +import { Secret, ServiceToken, Workspace, Integration } from '../../models'; + +/** + * Create new workspace environment named [environmentName] under workspace with id + * @param req + * @param res + * @returns + */ +export const createWorkspaceEnvironment = async ( + req: Request, + res: Response +) => { + const { workspaceId, environmentName, environmentSlug } = req.body; + try { + // atomic create the environment + const workspace = await Workspace.findOneAndUpdate( + { + _id: workspaceId, + 'environments.slug': { $ne: environmentSlug }, + 'environments.name': { $ne: environmentName }, + }, + { + $addToSet: { + environments: { name: environmentName, slug: environmentSlug }, + }, + } + ); + + if (!workspace) { + throw new Error('Failed to update workspace environment'); + } + } catch (err) { + Sentry.setUser({ email: req.user.email }); + Sentry.captureException(err); + return res.status(400).send({ + message: 'Failed to create new workspace environment', + }); + } + + return res.status(200).send({ + message: 'Successfully created new environment', + workspace: workspaceId, + environment: { + name: environmentName, + slug: environmentSlug, + }, + }); +}; + +/** + * Rename workspace environment with new name and slug of a workspace with [workspaceId] + * Old slug [oldEnvironmentSlug] must be provided + * @param req + * @param res + * @returns + */ +export const renameWorkspaceEnvironment = async ( + req: Request, + res: Response +) => { + const { workspaceId, environmentName, environmentSlug, oldEnvironmentSlug } = + req.body; + try { + // user should pass both new slug and env name + if (!environmentSlug || !environmentName) { + throw new Error('Invalid environment given.'); + } + + // atomic update the env to avoid conflict + const workspace = await Workspace.findOneAndUpdate( + { _id: workspaceId, 'environments.slug': oldEnvironmentSlug }, + { + 'environments.$.name': environmentName, + 'environments.$.slug': environmentSlug, + } + ); + if (!workspace) { + throw new Error('Failed to update workspace'); + } + + await Secret.updateMany( + { workspace: workspaceId, environment: oldEnvironmentSlug }, + { environment: environmentSlug } + ); + await ServiceToken.updateMany( + { workspace: workspaceId, environment: oldEnvironmentSlug }, + { environment: environmentSlug } + ); + await Integration.updateMany( + { workspace: workspaceId, environment: oldEnvironmentSlug }, + { environment: environmentSlug } + ); + } catch (err) { + Sentry.setUser({ email: req.user.email }); + Sentry.captureException(err); + return res.status(400).send({ + message: 'Failed to update workspace environment', + }); + } + + return res.status(200).send({ + message: 'Successfully update environment', + workspace: workspaceId, + environment: { + name: environmentName, + slug: environmentSlug, + }, + }); +}; + +/** + * Delete workspace environment by [environmentSlug] of workspace [workspaceId] and do the clean up + * @param req + * @param res + * @returns + */ +export const deleteWorkspaceEnvironment = async ( + req: Request, + res: Response +) => { + const { workspaceId, environmentSlug } = req.body; + try { + // atomic delete the env in the workspacce + const workspace = await Workspace.findOneAndUpdate( + { _id: workspaceId }, + { + $pull: { + environments: { + slug: environmentSlug, + }, + }, + } + ); + if (!workspace) { + throw new Error('Failed to delete workspace environment'); + } + + // clean up + await Secret.deleteMany({ + workspace: workspaceId, + environment: environmentSlug, + }); + await ServiceToken.deleteMany({ + workspace: workspaceId, + environment: environmentSlug, + }); + await Integration.deleteMany({ + workspace: workspaceId, + environment: environmentSlug, + }); + + } catch (err) { + Sentry.setUser({ email: req.user.email }); + Sentry.captureException(err); + return res.status(400).send({ + message: 'Failed to delete workspace environment', + }); + } + + return res.status(200).send({ + message: 'Successfully deleted environment', + workspace: workspaceId, + environment: environmentSlug, + }); +}; diff --git a/backend/src/controllers/v2/index.ts b/backend/src/controllers/v2/index.ts index 1651c09ee..601314a2a 100644 --- a/backend/src/controllers/v2/index.ts +++ b/backend/src/controllers/v2/index.ts @@ -3,11 +3,13 @@ import * as serviceTokenDataController from './serviceTokenDataController'; import * as apiKeyDataController from './apiKeyDataController'; import * as secretController from './secretController'; import * as secretsController from './secretsController'; +import * as environmentController from './environmentController'; export { workspaceController, serviceTokenDataController, apiKeyDataController, secretController, - secretsController + secretsController, + environmentController } diff --git a/backend/src/controllers/v2/workspaceController.ts b/backend/src/controllers/v2/workspaceController.ts index 0dbdfa076..efac159a5 100644 --- a/backend/src/controllers/v2/workspaceController.ts +++ b/backend/src/controllers/v2/workspaceController.ts @@ -19,7 +19,6 @@ import { import { pushKeys } from '../../helpers/key'; import { postHogClient, EventService } from '../../services'; import { eventPushSecrets } from '../../events'; -import { ENV_SET } from '../../variables'; interface V2PushSecret { type: string; // personal or shared @@ -52,7 +51,8 @@ export const pushWorkspaceSecrets = async (req: Request, res: Response) => { const { workspaceId } = req.params; // validate environment - if (!ENV_SET.has(environment)) { + const workspaceEnvs = req.membership.workspace.environments; + if (!workspaceEnvs.find(({ slug }: { slug: string }) => slug === environment)) { throw new Error('Failed to validate environment'); } @@ -129,6 +129,11 @@ export const pullSecrets = async (req: Request, res: Response) => { } else if (req.serviceTokenData) { userId = req.serviceTokenData.user._id } + // validate environment + const workspaceEnvs = req.membership.workspace.environments; + if (!workspaceEnvs.find(({ slug }: { slug: string }) => slug === environment)) { + throw new Error('Failed to validate environment'); + } secrets = await pull({ userId, diff --git a/backend/src/helpers/integration.ts b/backend/src/helpers/integration.ts index d92156ece..f602f1729 100644 --- a/backend/src/helpers/integration.ts +++ b/backend/src/helpers/integration.ts @@ -7,8 +7,6 @@ import { import { exchangeCode, exchangeRefresh, syncSecrets } from '../integrations'; import { BotService } from '../services'; import { - ENV_DEV, - EVENT_PUSH_SECRETS, INTEGRATION_VERCEL, INTEGRATION_NETLIFY } from '../variables'; @@ -36,11 +34,13 @@ interface Update { const handleOAuthExchangeHelper = async ({ workspaceId, integration, - code + code, + environment }: { workspaceId: string; integration: string; code: string; + environment: string; }) => { let action; let integrationAuth; @@ -102,9 +102,9 @@ const handleOAuthExchangeHelper = async ({ // initialize new integration after exchange await new Integration({ workspace: workspaceId, - environment: ENV_DEV, isActive: false, app: null, + environment, integration, integrationAuth: integrationAuth._id }).save(); diff --git a/backend/src/models/integration.ts b/backend/src/models/integration.ts index 6da699216..0b184ed67 100644 --- a/backend/src/models/integration.ts +++ b/backend/src/models/integration.ts @@ -1,9 +1,5 @@ import { Schema, model, Types } from 'mongoose'; import { - ENV_DEV, - ENV_TESTING, - ENV_STAGING, - ENV_PROD, INTEGRATION_HEROKU, INTEGRATION_VERCEL, INTEGRATION_NETLIFY, @@ -32,7 +28,6 @@ const integrationSchema = new Schema( }, environment: { type: String, - enum: [ENV_DEV, ENV_TESTING, ENV_STAGING, ENV_PROD], required: true }, isActive: { diff --git a/backend/src/models/secret.ts b/backend/src/models/secret.ts index a01b92d80..6887c8b0f 100644 --- a/backend/src/models/secret.ts +++ b/backend/src/models/secret.ts @@ -2,10 +2,6 @@ import { Schema, model, Types } from 'mongoose'; import { SECRET_SHARED, SECRET_PERSONAL, - ENV_DEV, - ENV_TESTING, - ENV_STAGING, - ENV_PROD } from '../variables'; export interface ISecret { @@ -53,7 +49,6 @@ const secretSchema = new Schema( }, environment: { type: String, - enum: [ENV_DEV, ENV_TESTING, ENV_STAGING, ENV_PROD], required: true }, secretKeyCiphertext: { diff --git a/backend/src/models/serviceToken.ts b/backend/src/models/serviceToken.ts index b5a2f4ec9..9d91b076e 100644 --- a/backend/src/models/serviceToken.ts +++ b/backend/src/models/serviceToken.ts @@ -1,7 +1,4 @@ import { Schema, model, Types } from 'mongoose'; -import { ENV_DEV, ENV_TESTING, ENV_STAGING, ENV_PROD } from '../variables'; - -// TODO: deprecate export interface IServiceToken { _id: Types.ObjectId; name: string; @@ -33,7 +30,6 @@ const serviceTokenSchema = new Schema( }, environment: { type: String, - enum: [ENV_DEV, ENV_TESTING, ENV_STAGING, ENV_PROD], required: true }, expiresAt: { diff --git a/backend/src/models/workspace.ts b/backend/src/models/workspace.ts index 1e886c523..fa7dbc8b5 100644 --- a/backend/src/models/workspace.ts +++ b/backend/src/models/workspace.ts @@ -4,6 +4,10 @@ export interface IWorkspace { _id: Types.ObjectId; name: string; organization: Types.ObjectId; + environments: Array<{ + name: string; + slug: string; + }>; } const workspaceSchema = new Schema({ @@ -15,7 +19,33 @@ const workspaceSchema = new Schema({ type: Schema.Types.ObjectId, ref: 'Organization', required: true - } + }, + environments: { + type: [ + { + name: String, + slug: String, + }, + ], + default: [ + { + name: "development", + slug: "dev" + }, + { + name: "test", + slug: "test" + }, + { + name: "staging", + slug: "staging" + }, + { + name: "production", + slug: "prod" + } + ], + }, }); const Workspace = model('Workspace', workspaceSchema); diff --git a/backend/src/routes/v2/environment.ts b/backend/src/routes/v2/environment.ts new file mode 100644 index 000000000..592a51248 --- /dev/null +++ b/backend/src/routes/v2/environment.ts @@ -0,0 +1,54 @@ +import express from 'express'; +const router = express.Router(); +import { body, param } from 'express-validator'; +import { environmentController } from '../../controllers/v2'; +import { + requireAuth, + requireWorkspaceAuth, + validateRequest, +} from '../../middleware'; +import { ADMIN, MEMBER, COMPLETED, GRANTED } from '../../variables'; + +router.post( + '/:workspaceId', + requireAuth, + requireWorkspaceAuth({ + acceptedRoles: [ADMIN, MEMBER], + acceptedStatuses: [COMPLETED, GRANTED], + }), + param('workspaceId').exists().trim(), + body('environmentSlug').exists().trim(), + body('environmentName').exists().trim(), + validateRequest, + environmentController.createWorkspaceEnvironment +); + +router.put( + '/:workspaceId', + requireAuth, + requireWorkspaceAuth({ + acceptedRoles: [ADMIN, MEMBER], + acceptedStatuses: [COMPLETED, GRANTED], + }), + param('workspaceId').exists().trim(), + body('environmentSlug').exists().trim(), + body('environmentName').exists().trim(), + body('oldEnvironmentSlug').exists().trim(), + validateRequest, + environmentController.renameWorkspaceEnvironment +); + +router.delete( + '/:workspaceId', + requireAuth, + requireWorkspaceAuth({ + acceptedRoles: [ADMIN], + acceptedStatuses: [GRANTED], + }), + param('workspaceId').exists().trim(), + body('environmentSlug').exists().trim(), + validateRequest, + environmentController.deleteWorkspaceEnvironment +); + +export default router; diff --git a/backend/src/routes/v2/index.ts b/backend/src/routes/v2/index.ts index 8bea42620..2740c3530 100644 --- a/backend/src/routes/v2/index.ts +++ b/backend/src/routes/v2/index.ts @@ -3,11 +3,13 @@ import secrets from './secrets'; import workspace from './workspace'; import serviceTokenData from './serviceTokenData'; import apiKeyData from './apiKeyData'; +import environment from "./environment" export { secret, secrets, workspace, serviceTokenData, - apiKeyData -} + apiKeyData, + environment +} \ No newline at end of file diff --git a/backend/src/services/IntegrationService.ts b/backend/src/services/IntegrationService.ts index 32f5f5a88..43746aee4 100644 --- a/backend/src/services/IntegrationService.ts +++ b/backend/src/services/IntegrationService.ts @@ -11,10 +11,6 @@ import { setIntegrationAuthAccessHelper, } from '../helpers/integration'; import { exchangeCode } from '../integrations'; -import { - ENV_DEV, - EVENT_PUSH_SECRETS -} from '../variables'; // should sync stuff be here too? Probably. // TODO: move bot functions to IntegrationService. @@ -32,22 +28,26 @@ class IntegrationService { * - Create bot sequence for integration * @param {Object} obj * @param {String} obj.workspaceId - id of workspace + * @param {String} obj.environment - workspace environment * @param {String} obj.integration - name of integration * @param {String} obj.code - code */ static async handleOAuthExchange({ workspaceId, integration, - code + code, + environment }: { workspaceId: string; integration: string; code: string; + environment: string; }) { await handleOAuthExchangeHelper({ workspaceId, integration, - code + code, + environment }); }