From ed7dbb655cffccdbd9b6f277ab6b4f8cb6cd0886 Mon Sep 17 00:00:00 2001 From: Tuan Dang Date: Wed, 12 Apr 2023 22:36:36 +0300 Subject: [PATCH] Updated bot, integration, and integrationAuth middlewares to support multiple clients --- backend/src/helpers/integration.ts | 112 +++++++++++++++++- backend/src/helpers/integrationAuth.ts | 103 ++++++++++++++++ backend/src/middleware/requireBotAuth.ts | 22 ++-- .../src/middleware/requireIntegrationAuth.ts | 38 ++---- .../requireIntegrationAuthorizationAuth.ts | 31 ++--- .../src/middleware/requireWorkspaceAuth.ts | 1 - backend/src/models/integrationAuth.ts | 4 +- backend/src/services/IntegrationService.ts | 5 +- backend/src/utils/errors.ts | 10 ++ 9 files changed, 257 insertions(+), 69 deletions(-) diff --git a/backend/src/helpers/integration.ts b/backend/src/helpers/integration.ts index 27afbb4b1..148ed511b 100644 --- a/backend/src/helpers/integration.ts +++ b/backend/src/helpers/integration.ts @@ -1,17 +1,42 @@ import * as Sentry from '@sentry/node'; +import { Types } from 'mongoose'; import { Bot, Integration, IntegrationAuth, + IUser, + User, + IServiceAccount, + ServiceAccount, + IServiceTokenData, + ServiceTokenData } from '../models'; import { exchangeCode, exchangeRefresh, syncSecrets } from '../integrations'; import { BotService } from '../services'; import { + AUTH_MODE_JWT, + AUTH_MODE_SERVICE_ACCOUNT, + AUTH_MODE_SERVICE_TOKEN, + AUTH_MODE_API_KEY, INTEGRATION_VERCEL, INTEGRATION_NETLIFY } from '../variables'; -import { UnauthorizedRequestError } from '../utils/errors'; +import { + UnauthorizedRequestError, + IntegrationAuthNotFoundError, + IntegrationNotFoundError +} from '../utils/errors'; import RequestError from '../utils/requestError'; +import { + validateClientForIntegrationAuth +} from '../helpers/integrationAuth'; +import { + validateUserClientForWorkspace +} from '../helpers/user'; +import { + validateServiceAccountClientForWorkspace +} from '../helpers/serviceAccount'; +import { IntegrationService } from '../services'; interface Update { workspace: string; @@ -20,6 +45,84 @@ interface Update { accountId?: string; } +/** + * Validate authenticated clients for integration with id [integrationId] based + * on any known permissions. + * @param {Object} obj + * @param {Object} obj.authData - authenticated client details + * @param {Types.ObjectId} obj.integrationId - id of integration to validate against + * @param {String} obj.environment - (optional) environment in workspace to validate against + * @param {Array<'admin' | 'member'>} obj.acceptedRoles - accepted workspace roles + * @param {String[]} obj.requiredPermissions - required permissions as part of the endpoint + */ + const validateClientForIntegration = async ({ + authData, + integrationId, + acceptedRoles +}: { + authData: { + authMode: string; + authPayload: IUser | IServiceAccount | IServiceTokenData; + }; + integrationId: Types.ObjectId; + acceptedRoles: Array<'admin' | 'member'>; +}) => { + + const integration = await Integration.findById(integrationId); + if (!integration) throw IntegrationNotFoundError(); + + const integrationAuth = await IntegrationAuth + .findById(integrationId) + .select( + '+refreshCiphertext +refreshIV +refreshTag +accessCiphertext +accessIV +accessTag +accessExpiresAt' + ); + + if (!integrationAuth) throw IntegrationAuthNotFoundError(); + + const accessToken = (await IntegrationService.getIntegrationAuthAccess({ + integrationAuthId: integrationAuth._id + })).accessToken; + + if (authData.authMode === AUTH_MODE_JWT && authData.authPayload instanceof User) { + await validateUserClientForWorkspace({ + user: authData.authPayload, + workspaceId: integration.workspace, + acceptedRoles + }); + + return ({ integration, accessToken }); + } + + if (authData.authMode === AUTH_MODE_SERVICE_ACCOUNT && authData.authPayload instanceof ServiceAccount) { + await validateServiceAccountClientForWorkspace({ + serviceAccount: authData.authPayload, + workspaceId: integration.workspace + }); + + return ({ integration, accessToken }); + } + + if (authData.authMode === AUTH_MODE_SERVICE_TOKEN && authData.authPayload instanceof ServiceTokenData) { + throw UnauthorizedRequestError({ + message: 'Failed service token authorization for integration' + }); + } + + if (authData.authMode === AUTH_MODE_API_KEY && authData.authPayload instanceof User) { + await validateUserClientForWorkspace({ + user: authData.authPayload, + workspaceId: integration.workspace, + acceptedRoles + }); + + return ({ integration, accessToken }); + } + + throw UnauthorizedRequestError({ + message: 'Failed client authorization for integration' + }); +} + /** * Perform OAuth2 code-token exchange for workspace with id [workspaceId] and integration * named [integration] @@ -140,7 +243,7 @@ const syncIntegrationsHelper = async ({ // get integration auth access token const access = await getIntegrationAuthAccessHelper({ - integrationAuthId: integration.integrationAuth.toString() + integrationAuthId: integration.integrationAuth }); // sync secrets to integration @@ -167,7 +270,7 @@ const syncIntegrationsHelper = async ({ * @param {String} obj.integrationAuthId - id of integration auth * @param {String} refreshToken - decrypted refresh token */ - const getIntegrationAuthRefreshHelper = async ({ integrationAuthId }: { integrationAuthId: string }) => { + const getIntegrationAuthRefreshHelper = async ({ integrationAuthId }: { integrationAuthId: Types.ObjectId }) => { let refreshToken; try { @@ -204,7 +307,7 @@ const syncIntegrationsHelper = async ({ * @param {String} obj.integrationAuthId - id of integration auth * @returns {String} accessToken - decrypted access token */ -const getIntegrationAuthAccessHelper = async ({ integrationAuthId }: { integrationAuthId: string }) => { +const getIntegrationAuthAccessHelper = async ({ integrationAuthId }: { integrationAuthId: Types.ObjectId }) => { let accessId; let accessToken; try { @@ -367,6 +470,7 @@ const setIntegrationAuthAccessHelper = async ({ } export { + validateClientForIntegration, handleOAuthExchangeHelper, syncIntegrationsHelper, getIntegrationAuthRefreshHelper, diff --git a/backend/src/helpers/integrationAuth.ts b/backend/src/helpers/integrationAuth.ts index e69de29bb..0b4556d4b 100644 --- a/backend/src/helpers/integrationAuth.ts +++ b/backend/src/helpers/integrationAuth.ts @@ -0,0 +1,103 @@ +import { Types } from 'mongoose'; +import { + IntegrationAuth, + IUser, + User, + IServiceAccount, + ServiceAccount, + IServiceTokenData, + ServiceTokenData, + IWorkspace +} from '../models'; +import { + AUTH_MODE_JWT, + AUTH_MODE_SERVICE_ACCOUNT, + AUTH_MODE_SERVICE_TOKEN, + AUTH_MODE_API_KEY +} from '../variables'; +import { + IntegrationAuthNotFoundError, + UnauthorizedRequestError +} from '../utils/errors'; +import { IntegrationService } from '../services'; +import { validateUserClientForWorkspace } from '../helpers/user'; +import { validateServiceAccountClientForWorkspace } from '../helpers/serviceAccount'; + +/** + * Validate authenticated clients for integration authorization with id [integrationAuthId] based + * on any known permissions. + * @param {Object} obj + * @param {Object} obj.authData - authenticated client details + * @param {Types.ObjectId} obj.integrationAuthId - id of integration authorization to validate against + * @param {Array<'admin' | 'member'>} obj.acceptedRoles - accepted workspace roles + * @param {String[]} obj.requiredPermissions - required permissions as part of the endpoint + */ + const validateClientForIntegrationAuth = async ({ + authData, + integrationId, + acceptedRoles +}: { + authData: { + authMode: string; + authPayload: IUser | IServiceAccount | IServiceTokenData; + }; + integrationId: Types.ObjectId; + acceptedRoles: Array<'admin' | 'member'>; +}) => { + + const integrationAuth = await IntegrationAuth + .findById(integrationId) + .populate<{ workspace: IWorkspace }>('workspace') + .select( + '+refreshCiphertext +refreshIV +refreshTag +accessCiphertext +accessIV +accessTag +accessExpiresAt' + ); + + if (!integrationAuth) throw IntegrationAuthNotFoundError(); + + const accessToken = (await IntegrationService.getIntegrationAuthAccess({ + integrationAuthId: integrationAuth._id + })).accessToken; + + if (authData.authMode === AUTH_MODE_JWT && authData.authPayload instanceof User) { + await validateUserClientForWorkspace({ + user: authData.authPayload, + workspaceId: integrationAuth.workspace._id, + acceptedRoles + }); + + return ({ integrationAuth, accessToken }); + } + + if (authData.authMode === AUTH_MODE_SERVICE_ACCOUNT && authData.authPayload instanceof ServiceAccount) { + await validateServiceAccountClientForWorkspace({ + serviceAccount: authData.authPayload, + workspaceId: integrationAuth.workspace._id + }); + + return ({ integrationAuth, accessToken }); + } + + if (authData.authMode === AUTH_MODE_SERVICE_TOKEN && authData.authPayload instanceof ServiceTokenData) { + throw UnauthorizedRequestError({ + message: 'Failed service token authorization for integration authorization' + }); + } + + if (authData.authMode === AUTH_MODE_API_KEY && authData.authPayload instanceof User) { + await validateUserClientForWorkspace({ + user: authData.authPayload, + workspaceId: integrationAuth.workspace._id, + acceptedRoles + }); + + return ({ integrationAuth, accessToken }); + } + + throw UnauthorizedRequestError({ + message: 'Failed client authorization for integration authorization' + }); +} + +export { + validateClientForIntegrationAuth +}; \ No newline at end of file diff --git a/backend/src/middleware/requireBotAuth.ts b/backend/src/middleware/requireBotAuth.ts index df7cf016e..089f570c8 100644 --- a/backend/src/middleware/requireBotAuth.ts +++ b/backend/src/middleware/requireBotAuth.ts @@ -1,34 +1,28 @@ import { Request, Response, NextFunction } from 'express'; +import { Types } from 'mongoose'; import { Bot } from '../models'; import { validateMembership } from '../helpers/membership'; +import { validateClientForBot } from '../helpers/bot'; import { AccountNotFoundError } from '../utils/errors'; type req = 'params' | 'body' | 'query'; -// TODO: transform - const requireBotAuth = ({ acceptedRoles, - location = 'params' + locationBotId = 'params' }: { acceptedRoles: Array<'admin' | 'member'>; - location?: req; + locationBotId?: req; }) => { return async (req: Request, res: Response, next: NextFunction) => { - const bot = await Bot.findById(req[location].botId); + const { botId } = req[locationBotId]; - if (!bot) { - return next(AccountNotFoundError({message: 'Failed to locate Bot account'})) - } - - await validateMembership({ - userId: req.user._id, - workspaceId: bot.workspace, + req.bot = await validateClientForBot({ + authData: req.authData, + botId: new Types.ObjectId(botId), acceptedRoles }); - req.bot = bot; - next(); } } diff --git a/backend/src/middleware/requireIntegrationAuth.ts b/backend/src/middleware/requireIntegrationAuth.ts index 7b6cac880..c93d3e690 100644 --- a/backend/src/middleware/requireIntegrationAuth.ts +++ b/backend/src/middleware/requireIntegrationAuth.ts @@ -1,7 +1,9 @@ import { Request, Response, NextFunction } from 'express'; +import { Types } from 'mongoose'; import { Integration, IntegrationAuth } from '../models'; import { IntegrationService } from '../services'; import { validateMembership } from '../helpers/membership'; +import { validateClientForIntegration } from '../helpers/integration'; import { IntegrationNotFoundError, UnauthorizedRequestError } from '../utils/errors'; /** @@ -19,36 +21,20 @@ const requireIntegrationAuth = ({ // integration authorization middleware const { integrationId } = req.params; - - // validate integration accessibility - const integration = await Integration.findOne({ - _id: integrationId - }); - - if (!integration) { - return next(IntegrationNotFoundError({message: 'Failed to locate Integration'})) - } - - await validateMembership({ - userId: req.user._id, - workspaceId: integration.workspace, + + const { integration, accessToken } = await validateClientForIntegration({ + authData: req.authData, + integrationId: new Types.ObjectId(integrationId), acceptedRoles }); - const integrationAuth = await IntegrationAuth.findOne({ - _id: integration.integrationAuth - }).select( - '+refreshCiphertext +refreshIV +refreshTag +accessCiphertext +accessIV +accessTag +accessExpiresAt' - ); - - if (!integrationAuth) { - return next(UnauthorizedRequestError({message: 'Failed to locate Integration Authentication credentials'})) + if (integration) { + req.integration = integration; + } + + if (accessToken) { + req.accessToken = accessToken; } - - req.integration = integration; - req.accessToken = await IntegrationService.getIntegrationAuthAccess({ - integrationAuthId: integrationAuth._id.toString() - }); return next(); }; diff --git a/backend/src/middleware/requireIntegrationAuthorizationAuth.ts b/backend/src/middleware/requireIntegrationAuthorizationAuth.ts index 8e28227f3..735df57ed 100644 --- a/backend/src/middleware/requireIntegrationAuthorizationAuth.ts +++ b/backend/src/middleware/requireIntegrationAuthorizationAuth.ts @@ -1,7 +1,9 @@ import * as Sentry from '@sentry/node'; +import { Types } from 'mongoose'; import { Request, Response, NextFunction } from 'express'; import { IntegrationAuth, IWorkspace } from '../models'; import { IntegrationService } from '../services'; +import { validateClientForIntegrationAuth } from '../helpers/integrationAuth'; import { validateMembership } from '../helpers/membership'; import { UnauthorizedRequestError } from '../utils/errors'; @@ -25,30 +27,19 @@ const requireIntegrationAuthorizationAuth = ({ }) => { return async (req: Request, res: Response, next: NextFunction) => { const { integrationAuthId } = req[location]; - const integrationAuth = await IntegrationAuth.findOne({ - _id: integrationAuthId - }) - .populate<{ workspace: IWorkspace }>('workspace') - .select( - '+refreshCiphertext +refreshIV +refreshTag +accessCiphertext +accessIV +accessTag +accessExpiresAt' - ); - - if (!integrationAuth) { - return next(UnauthorizedRequestError({message: 'Failed to locate Integration Authorization credentials'})) - } - await validateMembership({ - userId: req.user._id, - workspaceId: integrationAuth.workspace._id, + const { integrationAuth, accessToken } = await validateClientForIntegrationAuth({ + authData: req.authData, + integrationId: new Types.ObjectId(integrationAuthId), acceptedRoles }); + + if (integrationAuth) { + req.integrationAuth = integrationAuth; + } - req.integrationAuth = integrationAuth; - if (attachAccessToken) { - const access = await IntegrationService.getIntegrationAuthAccess({ - integrationAuthId: integrationAuth._id.toString() - }); - req.accessToken = access.accessToken; + if (accessToken) { + req.accessToken = accessToken; } return next(); diff --git a/backend/src/middleware/requireWorkspaceAuth.ts b/backend/src/middleware/requireWorkspaceAuth.ts index 9ca1b3f71..5d094f972 100644 --- a/backend/src/middleware/requireWorkspaceAuth.ts +++ b/backend/src/middleware/requireWorkspaceAuth.ts @@ -25,7 +25,6 @@ const requireWorkspaceAuth = ({ requiredPermissions?: string[]; }) => { return async (req: Request, res: Response, next: NextFunction) => { - const workspaceId = req[locationWorkspaceId]?.workspaceId; const environment = locationEnvironment ? req[locationEnvironment]?.environment : undefined; diff --git a/backend/src/models/integrationAuth.ts b/backend/src/models/integrationAuth.ts index 64c4f2d69..934005ec8 100644 --- a/backend/src/models/integrationAuth.ts +++ b/backend/src/models/integrationAuth.ts @@ -1,4 +1,4 @@ -import { Schema, model, Types } from "mongoose"; +import { Schema, model, Types, Document } from "mongoose"; import { INTEGRATION_AZURE_KEY_VAULT, INTEGRATION_AWS_PARAMETER_STORE, @@ -15,7 +15,7 @@ import { INTEGRATION_TRAVISCI, } from "../variables"; -export interface IIntegrationAuth { +export interface IIntegrationAuth extends Document { _id: Types.ObjectId; workspace: Types.ObjectId; integration: 'heroku' | 'vercel' | 'netlify' | 'github' | 'gitlab' | 'render' | 'railway' | 'flyio' | 'azure-key-vault' | 'circleci' | 'travisci' | 'aws-parameter-store' | 'aws-secret-manager'; diff --git a/backend/src/services/IntegrationService.ts b/backend/src/services/IntegrationService.ts index 7b3a20a6b..0fd634c33 100644 --- a/backend/src/services/IntegrationService.ts +++ b/backend/src/services/IntegrationService.ts @@ -1,3 +1,4 @@ +import { Types } from 'mongoose'; import { handleOAuthExchangeHelper, syncIntegrationsHelper, @@ -67,7 +68,7 @@ class IntegrationService { * @param {String} obj.integrationAuthId - id of integration auth * @param {String} refreshToken - decrypted refresh token */ - static async getIntegrationAuthRefresh({ integrationAuthId }: { integrationAuthId: string}) { + static async getIntegrationAuthRefresh({ integrationAuthId }: { integrationAuthId: Types.ObjectId}) { return await getIntegrationAuthRefreshHelper({ integrationAuthId }); @@ -80,7 +81,7 @@ class IntegrationService { * @param {String} obj.integrationAuthId - id of integration auth * @param {String} accessToken - decrypted access token */ - static async getIntegrationAuthAccess({ integrationAuthId }: { integrationAuthId: string}) { + static async getIntegrationAuthAccess({ integrationAuthId }: { integrationAuthId: Types.ObjectId }) { return await getIntegrationAuthAccessHelper({ integrationAuthId }); diff --git a/backend/src/utils/errors.ts b/backend/src/utils/errors.ts index 2542f84ff..987215091 100644 --- a/backend/src/utils/errors.ts +++ b/backend/src/utils/errors.ts @@ -73,6 +73,16 @@ export const ValidationError = (error?: Partial) => new Req stack: error?.stack }); +//* ----->[INTEGRATION AUTH ERRORS]<----- +export const IntegrationAuthNotFoundError = (error?: Partial) => new RequestError({ + logLevel: error?.logLevel ?? LogLevel.ERROR, + statusCode: error?.statusCode ?? 404, + type: error?.type ?? 'integration_auth_not_found_error', + message: error?.message ?? 'The requested integration authorization was not found', + context: error?.context, + stack: error?.stack +}); + //* ----->[INTEGRATION ERRORS]<----- export const IntegrationNotFoundError = (error?: Partial) => new RequestError({ logLevel: error?.logLevel ?? LogLevel.ERROR,