Begin developing bot, event, and integration pipeline

This commit is contained in:
Tuan Dang
2022-12-06 00:23:16 -05:00
parent c5a422fe64
commit 46fe724012
48 changed files with 1402 additions and 176 deletions

View File

@ -0,0 +1,7 @@
import {
actionPushToHeroku
} from './integration';
export {
actionPushToHeroku
}

View File

@ -0,0 +1,50 @@
import {
Key,
Bot,
IBot,
Integration,
IntegrationAuth
} from '../models';
import * as Sentry from '@sentry/node';
import { BotService } from '../services';
interface Event {
name: string;
workspaceId: string;
payload: any;
}
/**
* Push secrets to Heroku
* @param {Object} obj
* @param {Event} obj.event
* @param {IBot} obj.bot
*/
const actionPushToHeroku = ({
event,
bot
}: {
event: Event,
bot: IBot
}) => {
// TODO: push secrets in [event]
// event: name, workspaceId, payload (environment, secrets)
try {
// 1. Bot needs to decrypt their project key
// 2. Bot needs to decrypt secrets
// 3. Query IntegrationAuth for credentials
// 4. Decrypt integration refresh and token
// 5. Query Integration for integration details
// 6. Push to integration
} catch (err) {
Sentry.setUser(null);
Sentry.captureException(err);
}
}
export {
actionPushToHeroku
}

View File

@ -0,0 +1,96 @@
import { Request, Response } from 'express';
import * as Sentry from '@sentry/node';
import { Bot, BotKey } from '../models';
import { createBot } from '../helpers/bot';
interface BotKey {
encryptedKey: string;
nonce: string;
}
/**
* Return bot for workspace with id [workspaceId]. If a workspace bot doesn't exist,
* then create and return a new bot.
* @param req
* @param res
* @returns
*/
export const getBotByWorkspaceId = async (req: Request, res: Response) => {
let bot;
try {
const { workspaceId } = req.body;
bot = await Bot.findOne({
workspace: workspaceId
});
if (!bot) {
// case: bot doesn't exist for workspace with id [workspaceId]
// -> create a new bot and return it
bot = await createBot({
name: 'Infisical Bot',
workspaceId
});
}
} catch (err) {
Sentry.setUser({ email: req.user.email });
Sentry.captureException(err);
return res.status(400).send({
message: 'Failed to get bot for workspace'
});
}
return res.status(200).send({
bot
});
};
/**
* Return bot with id [req.bot._id] with active state set to [isActive].
* @param req
* @param res
* @returns
*/
export const setBotActiveState = async (req: Request, res: Response) => {
let bot;
try {
const { isActive, botKey }: { isActive: boolean, botKey: BotKey } = req.body;
if (isActive) {
// bot state set to active -> share workspace key with bot
await new BotKey({
encryptedKey: botKey.encryptedKey,
nonce: botKey.nonce,
sender: req.user._id,
receiver: req.bot._id,
workspace: req.bot.workspace
}).save();
} else {
// case: bot state set to inactive -> delete bot's workspace key
await BotKey.deleteOne({
bot: req.bot._id
});
}
bot = await Bot.findOneAndUpdate({
_id: req.bot._id
}, {
isActive
}, {
new: true
});
if (!bot) throw new Error('Failed to update bot active state');
} catch (err) {
Sentry.setUser({ email: req.user.email });
Sentry.captureException(err);
return res.status(400).send({
message: 'Failed to update bot active state'
});
}
return res.status(200).send({
bot
});
};

View File

@ -1,4 +1,5 @@
import * as authController from './authController';
import * as botController from './botController';
import * as integrationAuthController from './integrationAuthController';
import * as integrationController from './integrationController';
import * as keyController from './keyController';
@ -16,6 +17,7 @@ import * as workspaceController from './workspaceController';
export {
authController,
botController,
integrationAuthController,
integrationController,
keyController,

View File

@ -7,6 +7,8 @@ import { processOAuthTokenRes } from '../helpers/integrationAuth';
import { INTEGRATION_SET, ENV_DEV } from '../variables';
import { OAUTH_CLIENT_SECRET_HEROKU, OAUTH_TOKEN_URL_HEROKU } from '../config';
import { IntegrationService } from '../services';
/**
* Perform OAuth2 code-token exchange as part of integration [integration] for workspace with id [workspaceId]
* Note: integration [integration] must be set up compatible/designed for OAuth2
@ -14,58 +16,64 @@ import { OAUTH_CLIENT_SECRET_HEROKU, OAUTH_TOKEN_URL_HEROKU } from '../config';
* @param res
* @returns
*/
export const integrationAuthOauthExchange = async (
export const oAuthExchange = async (
req: Request,
res: Response
) => {
try {
let clientSecret;
// let clientSecret;
const { workspaceId, code, integration } = req.body;
if (!INTEGRATION_SET.has(integration))
throw new Error('Failed to validate integration');
// use correct client secret
switch (integration) {
case 'heroku':
clientSecret = OAUTH_CLIENT_SECRET_HEROKU;
}
// TODO: unfinished - make compatible with other integration types
const res = await axios.post(
OAUTH_TOKEN_URL_HEROKU!,
new URLSearchParams({
grant_type: 'authorization_code',
code: code,
client_secret: clientSecret
} as any)
);
const integrationAuth = await processOAuthTokenRes({
await IntegrationService.handleOAuthExchange({
workspaceId,
integration,
res
code
});
// create or replace integration
const integrationObj = await Integration.findOneAndUpdate(
{ workspace: workspaceId, integration },
{
workspace: workspaceId,
environment: ENV_DEV,
isActive: false,
app: null,
integration,
integrationAuth: integrationAuth._id
},
{ upsert: true, new: true }
);
// // use correct client secret
// switch (integration) {
// case 'heroku':
// clientSecret = OAUTH_CLIENT_SECRET_HEROKU;
// }
// // TODO: unfinished - make compatible with other integration types
// const res = await axios.post( // this response may be different for each integration
// OAUTH_TOKEN_URL_HEROKU!,
// new URLSearchParams({
// grant_type: 'authorization_code',
// code: code,
// client_secret: clientSecret
// } as any)
// );
// const integrationAuth = await processOAuthTokenRes({
// workspaceId,
// integration,
// res
// });
// // create or replace integration
// const integrationObj = await Integration.findOneAndUpdate(
// { workspace: workspaceId, integration },
// {
// workspace: workspaceId,
// environment: ENV_DEV,
// isActive: false,
// app: null,
// integration,
// integrationAuth: integrationAuth._id
// },
// { upsert: true, new: true }
// );
} catch (err) {
Sentry.setUser(null);
Sentry.captureException(err);
return res.status(400).send({
message: 'Failed to get OAuth2 token'
message: 'Failed to get OAuth2 code-token exchange'
});
}

View File

@ -49,6 +49,7 @@ export const getIntegrations = async (req: Request, res: Response) => {
});
};
// TODO: deprecate
/**
* Sync secrets [secrets] to integration with id [integrationId]
* @param req

View File

@ -17,16 +17,6 @@ export const uploadKey = async (req: Request, res: Response) => {
const { workspaceId } = req.params;
const { key } = req.body;
// validate membership of sender
const senderMembership = await findMembership({
user: req.user._id,
workspace: workspaceId
});
if (!senderMembership) {
throw new Error('Failed sender membership validation for workspace');
}
// validate membership of receiver
const receiverMembership = await findMembership({
user: key.userId,

View File

@ -7,8 +7,9 @@ import {
reformatPullSecrets
} from '../helpers/secret';
import { pushKeys } from '../helpers/key';
import { eventPushSecrets } from '../events';
import { EventService } from '../services';
import { ENV_SET } from '../variables';
import { postHogClient } from '../services';
interface PushSecret {
@ -60,7 +61,16 @@ export const pushSecrets = async (req: Request, res: Response) => {
workspaceId,
keys
});
// trigger event
EventService.handleEvent({
event: eventPushSecrets({
workspaceId,
environment,
secrets
})
});
if (postHogClient) {
postHogClient.capture({
event: 'secrets pushed',
@ -192,7 +202,7 @@ export const pullSecretsServiceToken = async (req: Request, res: Response) => {
};
if (postHogClient) {
// capture secrets pushed event in production
// capture secrets pulled event in production
postHogClient.capture({
distinctId: req.serviceToken.user.email,
event: 'secrets pulled',

View File

@ -0,0 +1,5 @@
import { eventPushSecrets } from "./secret"
export {
eventPushSecrets
}

View File

@ -0,0 +1,44 @@
import { EVENT_PUSH_SECRETS } from '../variables';
interface PushSecret {
ciphertextKey: string;
ivKey: string;
tagKey: string;
hashKey: string;
ciphertextValue: string;
ivValue: string;
tagValue: string;
hashValue: string;
type: 'shared' | 'personal';
}
/**
* Return event for pushing secrets
* @param {Object} obj
* @param {String} obj.workspaceId - id of workspace to push secrets to
* @param {String} obj.environment - environment for secrets
* @param {PushSecret[]} obj.secrets - secrets to push
* @returns
*/
const eventPushSecrets = ({
workspaceId,
environment,
secrets
}: {
workspaceId: string;
environment: string;
secrets: PushSecret[];
}) => {
return ({
name: EVENT_PUSH_SECRETS,
workspaceId,
payload: {
environment,
secrets
}
});
}
export {
eventPushSecrets
}

View File

@ -0,0 +1,49 @@
import * as Sentry from '@sentry/node';
import {
Bot
} from '../models';
import { generateKeyPair, encryptSymmetric } from '../utils/crypto';
import { ENCRYPTION_KEY } from '../config';
/**
* Create an inactive bot with name [name] for workspace with id [workspaceId]
* @param {Object} obj
* @param {String} obj.name - name of bot
* @param {String} obj.workspaceId - id of workspace that bot belongs to
*/
const createBot = async ({
name,
workspaceId,
}: {
name: string;
workspaceId: string;
}) => {
let bot;
try {
const { publicKey, privateKey } = generateKeyPair();
const { ciphertext, iv, tag } = encryptSymmetric({
plaintext: privateKey,
key: ENCRYPTION_KEY
});
bot = await new Bot({
name,
workspace: workspaceId,
isActive: false,
publicKey,
encryptedPrivateKey: ciphertext,
iv,
tag
}).save();
} catch (err) {
Sentry.setUser(null);
Sentry.captureException(err);
throw new Error('Failed to create bot');
}
return bot;
}
export {
createBot
}

View File

@ -9,6 +9,81 @@ import {
OAUTH_TOKEN_URL_HEROKU
} from '../config';
/**
* Encrypt access and refresh tokens, compute new access token expiration times [accessExpiresAt],
* and upsert them into the DB for workspace with id [workspaceId] and integration [integration].
* @param {Object} obj
* @param {String} obj.workspaceId - id of workspace
* @param {String} obj.integration - name of integration
* @param {String} obj.accessToken - access token for integration
* @param {Date} obj.accessExpiresAt - date of expiration for access token
* @param {String} obj.refreshToken - refresh token for integration
*/
const processOAuthTokenRes2 = async ({
workspaceId,
integration,
accessToken,
accessExpiresAt,
refreshToken,
}: {
workspaceId: string;
integration: string;
accessToken: string;
accessExpiresAt: Date;
refreshToken: string;
}) => {
let integrationAuth;
try {
// encrypt refresh + access tokens
const {
ciphertext: refreshCiphertext,
iv: refreshIV,
tag: refreshTag
} = encryptSymmetric({
plaintext: refreshToken,
key: ENCRYPTION_KEY
});
const {
ciphertext: accessCiphertext,
iv: accessIV,
tag: accessTag
} = encryptSymmetric({
plaintext: accessToken,
key: ENCRYPTION_KEY
});
// create or replace integration authorization with encrypted tokens
// and access token expiration date
integrationAuth = await IntegrationAuth.findOneAndUpdate(
{ workspace: workspaceId, integration },
{
workspace: workspaceId,
integration,
refreshCiphertext,
refreshIV,
refreshTag,
accessCiphertext,
accessIV,
accessTag,
accessExpiresAt
},
{ upsert: true, new: true }
);
} catch (err) {
Sentry.setUser(null);
Sentry.captureException(err);
throw new Error(
'Failed to process OAuth2 authorization server token response'
);
}
return integrationAuth;
}
// TODO: deprecate
/**
* Process token exchange and refresh responses from respective OAuth2 authorization servers by
* encrypting access and refresh tokens, computing new access token expiration times [accessExpiresAt],
@ -82,6 +157,7 @@ const processOAuthTokenRes = async ({
return integrationAuth;
};
// TODO: deprecate
/**
* Return access token for integration either by decrypting a non-expired access token [accessCiphertext] on
* the integration authorization document or by requesting a new one by decrypting and exchanging the
@ -171,4 +247,4 @@ const getOAuthAccessToken = async ({
return accessToken;
};
export { processOAuthTokenRes, getOAuthAccessToken };
export { processOAuthTokenRes, processOAuthTokenRes2, getOAuthAccessToken };

View File

@ -1,6 +1,51 @@
import * as Sentry from '@sentry/node';
import { Membership, Key } from '../models';
/**
* Validate that user with id [userId] is a member of workspace with id [workspaceId]
* and has at least one of the roles in [acceptedRoles] and statuses in [acceptedStatuses]
* @param {Object} obj
* @param {String} obj.userId - id of user to validate
* @param {String} obj.workspaceId - id of workspace
*/
const validateMembership = async ({
userId,
workspaceId,
acceptedRoles,
acceptedStatuses
}: {
userId: string;
workspaceId: string;
acceptedRoles: string[];
acceptedStatuses: string[];
}) => {
let membership;
try {
membership = await Membership.findOne({
user: userId,
workspace: workspaceId
});
if (!membership) throw new Error('Failed to find membership');
if (!acceptedRoles.includes(membership.role)) {
throw new Error('Failed to validate membership role');
}
if (!acceptedStatuses.includes(membership.status)) {
throw new Error('Failed to validate membership status');
}
} catch (err) {
Sentry.setUser(null);
Sentry.captureException(err);
throw new Error('Failed to validate membership');
}
return membership;
}
/**
* Return membership matching criteria specified in query [queryObj]
* @param {Object} queryObj - query object
@ -97,4 +142,9 @@ const deleteMembership = async ({ membershipId }: { membershipId: string }) => {
return deletedMembership;
};
export { addMemberships, findMembership, deleteMembership };
export {
validateMembership,
addMemberships,
findMembership,
deleteMembership
};

View File

@ -33,7 +33,7 @@ const sendEmailVerification = async ({ email }: { email: string }) => {
// send mail
await sendMail({
template: 'emailVerification.handlebars',
subjectLine: 'Infisical workspace invitation',
subjectLine: 'Infisical confirmation code',
recipients: [email],
substitutions: {
code: token

View File

@ -1,13 +1,16 @@
import * as Sentry from '@sentry/node';
import {
Workspace,
Bot,
Membership,
Key,
Secret
} from '../models';
import { createBot } from '../helpers/bot';
/**
* Create a workspace with name [name] in organization with id [organizationId]
* and a bot for it.
* @param {String} name - name of workspace to create.
* @param {String} organizationId - id of organization to create workspace in
* @param {Object} workspace - new workspace
@ -21,10 +24,16 @@ const createWorkspace = async ({
}) => {
let workspace;
try {
// create workspace
workspace = await new Workspace({
name,
organization: organizationId
}).save();
// const bot = await createBot({
// name: 'Infisical Bot',
// workspaceId: workspace._id.toString()
// });
} catch (err) {
Sentry.setUser(null);
Sentry.captureException(err);
@ -43,6 +52,9 @@ const createWorkspace = async ({
const deleteWorkspace = async ({ id }: { id: string }) => {
try {
await Workspace.deleteOne({ _id: id });
await Bot.deleteOne({
workspace: id
});
await Membership.deleteMany({
workspace: id
});

View File

@ -22,6 +22,7 @@ Sentry.init({
import {
signup as signupRouter,
auth as authRouter,
bot as botRouter,
organization as organizationRouter,
workspace as workspaceRouter,
membershipOrg as membershipOrgRouter,
@ -71,6 +72,7 @@ app.use(express.json());
// routers
app.use('/api/v1/signup', signupRouter);
app.use('/api/v1/auth', authRouter);
app.use('/api/v1/bot', botRouter);
app.use('/api/v1/user', userRouter);
app.use('/api/v1/user-action', userActionRouter);
app.use('/api/v1/organization', organizationRouter);

View File

@ -0,0 +1,96 @@
import axios from 'axios';
import * as Sentry from '@sentry/node';
import {
INTEGRATION_HEROKU,
ACTION_PUSH_TO_HEROKU
} from '../variables';
import {
OAUTH_CLIENT_SECRET_HEROKU,
OAUTH_TOKEN_URL_HEROKU
} from '../config';
/**
* Return [accessToken], [accessExpiresAt], and [refreshToken] for OAuth2
* code-token exchange for integration named [integration]
* @param {Object} obj1
* @param {String} obj1.integration - name of integration
* @param {String} obj1.code - code for code-token exchange
* @returns {Object} obj
* @returns {String} obj.accessToken - access token for integration
* @returns {String} obj.refreshToken - refresh token for integration
* @returns {Date} obj.accessExpiresAt - date of expiration for access token
* @returns {String} obj.action - integration action for bot sequence
*/
const exchangeCode = async ({
integration,
code
}: {
integration: string;
code: string;
}) => {
let obj = {} as any;
try {
switch (integration) {
case INTEGRATION_HEROKU:
obj = await exchangeCodeHeroku({
code
});
obj['action'] = ACTION_PUSH_TO_HEROKU;
break;
}
} catch (err) {
Sentry.setUser(null);
Sentry.captureException(err);
throw new Error('Failed OAuth2 code-token exchange');
}
return obj;
}
/**
* Return [accessToken], [accessExpiresAt], and [refreshToken] for Heroku
* OAuth2 code-token exchange
* @param {Object} obj1
* @param {Object} obj1.code - code for code-token exchange
* @returns {Object} obj2
* @returns {String} obj2.accessToken - access token for Heroku API
* @returns {String} obj2.refreshToken - refresh token for Heroku API
* @returns {Date} obj2.accessExpiresAt - date of expiration for access token
*/
const exchangeCodeHeroku = async ({
code
}: {
code: string;
}) => {
let res: any;
let accessExpiresAt: any;
try {
res = await axios.post(
OAUTH_TOKEN_URL_HEROKU!,
new URLSearchParams({
grant_type: 'authorization_code',
code: code,
client_secret: OAUTH_CLIENT_SECRET_HEROKU
} as any)
);
accessExpiresAt.setSeconds(
accessExpiresAt.getSeconds() + res.data.expires_in
);
} catch (err) {
console.error('integrationHerokuExchange');
Sentry.setUser(null);
Sentry.captureException(err);
throw new Error('Failed OAuth2 code-token exchange with Heroku');
}
return ({
accessToken: res.data.access_token,
refreshToken: res.data.refresh_token,
accessExpiresAt
});
}
export {
exchangeCode
}

View File

@ -0,0 +1,7 @@
import { exchangeCode } from './exchange';
import { exchangeRefresh } from './refresh';
export {
exchangeCode,
exchangeRefresh
}

View File

@ -0,0 +1,78 @@
import axios from 'axios';
import * as Sentry from '@sentry/node';
import { INTEGRATION_HEROKU } from '../variables';
import {
OAUTH_CLIENT_SECRET_HEROKU
} from '../config';
import {
OAUTH_TOKEN_URL_HEROKU
} from '../variables';
/**
* Return new access token by exchanging refresh token [refreshToken] for integration
* named [integration]
* @param {Object} obj
* @param {String} obj.integration - name of integration
* @param {String} obj.refreshToken - refresh token to use to get new access token for Heroku
*/
const exchangeRefresh = async ({
integration,
refreshToken
}: {
integration: string;
refreshToken: string;
}) => {
let accessToken;
try {
switch (integration) {
case INTEGRATION_HEROKU:
accessToken = await exchangeRefreshHeroku({
refreshToken
});
break;
}
} catch (err) {
Sentry.setUser(null);
Sentry.captureException(err);
throw new Error('Failed to get new OAuth2 access token');
}
return accessToken;
}
/**
* Return new access token by exchanging refresh token [refreshToken] for the
* Heroku integration
* @param {Object} obj
* @param {String} obj.refreshToken - refresh token to use to get new access token for Heroku
* @returns
*/
const exchangeRefreshHeroku = async ({
refreshToken
}: {
refreshToken: string;
}) => {
let accessToken;
try {
const res = await axios.post(
OAUTH_TOKEN_URL_HEROKU,
new URLSearchParams({
grant_type: 'refresh_token',
refresh_token: refreshToken,
client_secret: OAUTH_CLIENT_SECRET_HEROKU
} as any)
);
accessToken = res.data.access_token;
} catch (err) {
Sentry.setUser(null);
Sentry.captureException(err);
throw new Error('Failed to get new OAuth2 access token for Heroku');
}
return accessToken;
}
export {
exchangeRefresh
}

View File

@ -1,4 +1,5 @@
import requireAuth from './requireAuth';
import requireBotAuth from './requireBotAuth';
import requireSignupAuth from './requireSignupAuth';
import requireWorkspaceAuth from './requireWorkspaceAuth';
import requireOrganizationAuth from './requireOrganizationAuth';
@ -9,6 +10,7 @@ import validateRequest from './validateRequest';
export {
requireAuth,
requireBotAuth,
requireSignupAuth,
requireWorkspaceAuth,
requireOrganizationAuth,

View File

@ -0,0 +1,45 @@
import * as Sentry from '@sentry/node';
import { Request, Response, NextFunction } from 'express';
import { Bot } from '../models';
import { validateMembership } from '../helpers/membership';
type req = 'params' | 'body' | 'query';
const requireBotAuth = ({
acceptedRoles,
acceptedStatuses,
location = 'params'
}: {
acceptedRoles: string[];
acceptedStatuses: string[];
location?: req;
}) => {
return async (req: Request, res: Response, next: NextFunction) => {
try {
const bot = await Bot.findOne({ _id: req[location].botId });
if (!bot) {
throw new Error('Failed to find bot');
}
await validateMembership({
userId: req.user._id.toString(),
workspaceId: bot.workspace.toString(),
acceptedRoles,
acceptedStatuses
});
req.bot = bot;
next();
} catch (err) {
Sentry.setUser(null);
Sentry.captureException(err);
return res.status(401).send({
error: 'Failed bot authorization'
});
}
}
}
export default requireBotAuth;

View File

@ -2,6 +2,7 @@ import * as Sentry from '@sentry/node';
import { Request, Response, NextFunction } from 'express';
import { Integration, IntegrationAuth, Membership } from '../models';
import { getOAuthAccessToken } from '../helpers/integrationAuth';
import { validateMembership } from '../helpers/membership';
/**
* Validate if user on request is a member of workspace with proper roles associated
@ -31,24 +32,14 @@ const requireIntegrationAuth = ({
if (!integration) {
throw new Error('Failed to find integration');
}
const membership = await Membership.findOne({
user: req.user._id,
workspace: integration.workspace
await validateMembership({
userId: req.user._id.toString(),
workspaceId: integration.workspace.toString(),
acceptedRoles,
acceptedStatuses
});
if (!membership) {
throw new Error('Failed to find integration workspace membership');
}
if (!acceptedRoles.includes(membership.role)) {
throw new Error('Failed to validate workspace membership role');
}
if (!acceptedStatuses.includes(membership.status)) {
throw new Error('Failed to validate workspace membership status');
}
const integrationAuth = await IntegrationAuth.findOne({
_id: integration.integrationAuth
}).select(

View File

@ -1,8 +1,8 @@
import * as Sentry from '@sentry/node';
import { Request, Response, NextFunction } from 'express';
import { IntegrationAuth, Membership } from '../models';
import { decryptSymmetric } from '../utils/crypto';
import { IntegrationAuth } from '../models';
import { getOAuthAccessToken } from '../helpers/integrationAuth';
import { validateMembership } from '../helpers/membership';
/**
* Validate if user on request is a member of workspace with proper roles associated
@ -20,8 +20,6 @@ const requireIntegrationAuthorizationAuth = ({
acceptedStatuses: string[];
}) => {
return async (req: Request, res: Response, next: NextFunction) => {
// (authorization) integration authorization middleware
try {
const { integrationAuthId } = req.params;
@ -34,29 +32,15 @@ const requireIntegrationAuthorizationAuth = ({
if (!integrationAuth) {
throw new Error('Failed to find integration authorization');
}
const membership = await Membership.findOne({
user: req.user._id,
workspace: integrationAuth.workspace
await validateMembership({
userId: req.user._id.toString(),
workspaceId: integrationAuth.workspace.toString(),
acceptedRoles,
acceptedStatuses
});
if (!membership) {
throw new Error(
'Failed to find integration authorization workspace membership'
);
}
if (!acceptedRoles.includes(membership.role)) {
throw new Error('Failed to validate workspace membership role');
}
if (!acceptedStatuses.includes(membership.status)) {
throw new Error('Failed to validate workspace membership status');
}
req.integrationAuth = integrationAuth;
// TODO: make compatible with other integration types since they won't necessarily have access tokens
req.accessToken = await getOAuthAccessToken({ integrationAuth });
return next();
} catch (err) {

View File

@ -1,6 +1,6 @@
import * as Sentry from '@sentry/node';
import { Request, Response, NextFunction } from 'express';
import { Membership, IWorkspace } from '../models';
import { validateMembership } from '../helpers/membership';
type req = 'params' | 'body' | 'query';
@ -25,24 +25,12 @@ const requireWorkspaceAuth = ({
// workspace authorization middleware
try {
// validate workspace membership
const membership = await Membership.findOne({
user: req.user._id,
workspace: req[location].workspaceId
}).populate<{ workspace: IWorkspace }>('workspace');
if (!membership) {
throw new Error('Failed to find workspace membership');
}
if (!acceptedRoles.includes(membership.role)) {
throw new Error('Failed to validate workspace membership role');
}
if (!acceptedStatuses.includes(membership.status)) {
throw new Error('Failed to validate workspace membership status');
}
const membership = await validateMembership({
userId: req.user._id.toString(),
workspaceId: req[location].workspaceId,
acceptedRoles,
acceptedStatuses
});
req.membership = membership;

57
backend/src/models/bot.ts Normal file
View File

@ -0,0 +1,57 @@
import { Schema, model, Types } from 'mongoose';
export interface IBot {
_id: Types.ObjectId;
name: string;
workspace: Types.ObjectId;
isActive: boolean;
publicKey: string;
encryptedPrivateKey: string;
iv: string;
tag: string;
}
const botSchema = new Schema<IBot>(
{
name: {
type: String,
required: true
},
workspace: {
type: Schema.Types.ObjectId,
ref: 'Workspace',
required: true
},
isActive: {
type: Boolean,
required: true
},
publicKey: {
type: String,
required: true,
select: false
},
encryptedPrivateKey: {
type: String,
required: true,
select: false
},
iv: {
type: String,
required: true,
select: false
},
tag: {
type: String,
required: true,
select: false
}
},
{
timestamps: true
}
);
const Bot = model<IBot>('Bot', botSchema);
export default Bot;

View File

@ -0,0 +1,45 @@
import { Schema, model, Types } from 'mongoose';
export interface IBotKey {
_id: Types.ObjectId;
encryptedKey: string;
nonce: string;
sender: Types.ObjectId;
bot: Types.ObjectId;
workspace: Types.ObjectId;
}
const botKeySchema = new Schema<IBotKey>(
{
encryptedKey: {
type: String,
required: true
},
nonce: {
type: String,
required: true
},
sender: {
type: Schema.Types.ObjectId,
ref: 'User',
required: true
},
bot: {
type: Schema.Types.ObjectId,
ref: 'Bot',
required: true
},
workspace: {
type: Schema.Types.ObjectId,
ref: 'Workspace',
required: true
}
},
{
timestamps: true
}
);
const BotKey = model<IBotKey>('BotKey', botKeySchema);
export default BotKey;

View File

@ -0,0 +1,38 @@
import { Schema, model, Types } from 'mongoose';
export interface IBotSequence {
_id: Types.ObjectId;
bot: Types.ObjectId;
name: string;
event: string;
action: string;
}
const botSequence = new Schema<IBotSequence>(
{
bot: {
type: Schema.Types.ObjectId,
ref: 'Bot',
required: true
},
name: {
type: String,
required: true
},
event: {
type: String,
required: true
},
action: {
type: String,
required: true
}
},
{
timestamps: true
}
);
const BotSequence = model<IBotSequence>('BotSequence', botSequence);
export default BotSequence;

View File

@ -1,4 +1,7 @@
import BackupPrivateKey, { IBackupPrivateKey } from './backupPrivateKey';
import Bot, { IBot } from './bot';
import BotKey, { IBotKey } from './botKey';
import BotSequence, { IBotSequence } from './botSequence';
import IncidentContactOrg, { IIncidentContactOrg } from './incidentContactOrg';
import Integration, { IIntegration } from './integration';
import IntegrationAuth, { IIntegrationAuth } from './integrationAuth';
@ -16,6 +19,12 @@ import Workspace, { IWorkspace } from './workspace';
export {
BackupPrivateKey,
IBackupPrivateKey,
Bot,
IBot,
BotKey,
IBotKey,
BotSequence,
IBotSequence,
IncidentContactOrg,
IIncidentContactOrg,
Integration,

39
backend/src/routes/bot.ts Normal file
View File

@ -0,0 +1,39 @@
import express from 'express';
const router = express.Router();
import { body } from 'express-validator';
import {
requireAuth,
requireBotAuth,
requireWorkspaceAuth,
validateRequest
} from '../middleware';
import { botController } from '../controllers';
import { ADMIN, MEMBER, COMPLETED, GRANTED } from '../variables';
router.get(
'/',
requireAuth,
requireWorkspaceAuth({
acceptedRoles: [ADMIN, MEMBER],
acceptedStatuses: [COMPLETED, GRANTED],
location: 'body'
}),
body('workspaceId').exists().trim().notEmpty(),
validateRequest,
botController.getBotByWorkspaceId
);
router.patch(
'/:botId/active',
requireAuth,
requireBotAuth({
acceptedRoles: [ADMIN, MEMBER],
acceptedStatuses: [COMPLETED, GRANTED]
}),
body('isActive').isBoolean(),
body('botKey'),
validateRequest,
botController.setBotActiveState
);
export default router;

View File

@ -1,4 +1,5 @@
import signup from './signup';
import bot from './bot';
import auth from './auth';
import user from './user';
import userAction from './userAction';
@ -18,6 +19,7 @@ import integrationAuth from './integrationAuth';
export {
signup,
auth,
bot,
user,
userAction,
organization,

View File

@ -11,7 +11,7 @@ import { integrationController } from '../controllers';
router.get('/integrations', requireAuth, integrationController.getIntegrations);
router.post(
router.post( // TODO: deprecate
'/:integrationId/sync',
requireAuth,
requireIntegrationAuth({

View File

@ -10,7 +10,7 @@ import {
import { ADMIN, MEMBER, GRANTED } from '../variables';
import { integrationAuthController } from '../controllers';
router.post(
router.post( // semi-ok
'/oauth-token',
requireAuth,
requireWorkspaceAuth({
@ -22,10 +22,10 @@ router.post(
body('code').exists().trim().notEmpty(),
body('integration').exists().trim().notEmpty(),
validateRequest,
integrationAuthController.integrationAuthOauthExchange
integrationAuthController.oAuthExchange
);
router.get(
router.get( // not-ok
'/:integrationAuthId/apps',
requireAuth,
requireIntegrationAuthorizationAuth({
@ -37,7 +37,7 @@ router.get(
integrationAuthController.getIntegrationAuthApps
);
router.delete(
router.delete( // not-ok
'/:integrationAuthId',
requireAuth,
requireIntegrationAuthorizationAuth({

View File

@ -0,0 +1,54 @@
import * as Sentry from '@sentry/node';
import { IBot } from '../models';
import { ACTION_PUSH_TO_HEROKU } from '../variables';
import { actionPushToHeroku } from '../actions';
interface Event {
name: string;
workspaceId: string;
payload: any;
}
/**
* Class to handle actions
*/
class ActionService {
/**
* @param {Object} obj
* @param {String} action - name of action to trigger
* @param {Event} event
* @param {String} obj.event.name - name of event
* @param {String} obj.event.workspaceId - id of workspace that event is part of
* @param {Object} obj.event.payload - payload of event (depends on event)
* @param bot
* @returns
*/
static async handleAction({
action,
event,
bot
}: {
action: string;
event: Event;
bot: IBot;
}) {
try {
switch (action) {
case ACTION_PUSH_TO_HEROKU:
actionPushToHeroku({
event,
bot
});
return;
default:
return;
}
} catch (err) {
console.error('EventService err', err);
Sentry.setUser(null);
Sentry.captureException(err);
}
}
}
export default ActionService;

View File

@ -0,0 +1,108 @@
import {
Bot,
IBot,
BotKey,
IBotKey,
IUser,
Secret
} from '../models';
import * as Sentry from '@sentry/node';
import {
decryptAsymmetric,
decryptSymmetric
} from '../utils/crypto';
import {
ENCRYPTION_KEY
} from '../config';
/**
* Class to handle bot actions
*/
class BotService {
/**
* Return decrypted secrets using bot
* @param {Object} obj
* @param {String} obj.workspaceId - id of workspace of secrets
* @param {String} obj.environment - environment for secrets
*/
static async decryptSecrets({
workspaceId,
environment
}: {
workspaceId: string;
environment: string;
}) {
let content: any = {};
let bot;
let botKey;
try {
// find bot
bot = await Bot.findOne({
workspace: workspaceId,
isActive: true
});
if (!bot) throw new Error('Failed to find bot');
// find bot key
botKey = await BotKey.findOne({
workspace: workspaceId
}).populate<{ sender: IUser }>('sender');
if (!botKey) throw new Error('Failed to find bot key');
// decrypt bot private key
const privateKey = decryptSymmetric({
ciphertext: bot.encryptedPrivateKey,
iv: bot.iv,
tag: bot.tag,
key: ENCRYPTION_KEY
});
// decrypt workspace key
const key = decryptAsymmetric({
ciphertext: botKey.encryptedKey,
nonce: botKey.nonce,
publicKey: botKey.sender.publicKey as string,
privateKey
});
// decrypt secrets
const secrets = await Secret.find({
workspace: workspaceId,
environment
});
secrets.forEach(secret => {
// KEY, VALUE
const secretKey = decryptSymmetric({
ciphertext: secret.secretKeyCiphertext,
iv: secret.secretKeyIV,
tag: secret.secretKeyTag,
key
});
const secretValue = decryptSymmetric({
ciphertext: secret.secretValueCiphertext,
iv: secret.secretValueIV,
tag: secret.secretValueTag,
key
});
content[secretKey] = secretValue;
});
} catch (err) {
console.error('BotService');
Sentry.setUser(null);
Sentry.captureException(err);
}
return content;
}
}
export default BotService;

View File

@ -0,0 +1,75 @@
import { Bot, IBot, BotSequence } from '../models';
import * as Sentry from '@sentry/node';
import ActionService from './ActionService';
interface Event {
name: string;
workspaceId: string;
payload: any;
}
/**
* Class to handle events. TODO: elaborate DOCSTRING.
*/
class EventService {
/**
* Check if any bot sequences exist for event and forward
* bot sequence details to ActionService for execution
* @param {Object} obj
* @param {Event} obj.event - an event
* @param {String} obj.event.name - name of event
* @param {String} obj.event.workspaceId - id of workspace that event is part of
* @param {Object} obj.event.payload - payload of event (depends on event)
*/
static async handleEvent({ event }: { event: Event }): Promise<void> {
let botSequences;
let bot: IBot | null;
try {
console.log('EventService');
const { workspaceId } = event;
bot = await Bot.findOne({
workspace: workspaceId,
isActive: true
});
console.log('A', bot);
// case: bot doesn't exist
if (!bot) {
return;
}
botSequences = await BotSequence.find({
bot: bot._id,
event: event.name
});
console.log('B', botSequences);
// case: bot sequences don't exist
if (botSequences.length === 0) return;
console.log('C');
return;
// // execute event sequences
// botSequences.forEach(botSequence => {
// // sequence.actions
// ActionService.handleAction({
// action: botSequence.action,
// event,
// bot: bot as IBot
// });
// });
} catch (err) {
console.error('EventService err', err);
Sentry.setUser(null);
Sentry.captureException(err);
}
}
}
export default EventService;

View File

@ -0,0 +1,93 @@
import * as Sentry from '@sentry/node';
import {
Integration,
Bot,
BotSequence
} from '../models';
import { exchangeCode } from '../integrations';
import { processOAuthTokenRes2 } from '../helpers/integrationAuth';
import {
ENV_DEV,
EVENT_PUSH_SECRETS
} from '../variables';
/**
* Class to handle integrations
*/
class IntegrationService {
/**
* Perform OAuth2 code-token exchange for workspace with id [workspaceId] and integration
* named [integration]
* - Store integration access and refresh tokens returned from the OAuth2 code-token exchange
* - Add placeholder inactive integration
* - Create bot sequence for integration
* @param {Object} obj
* @param {String} obj.workspaceId - id of workspace
* @param {String} obj.integration - name of integration
* @param {String} obj.code - code
*/
static async handleOAuthExchange({
workspaceId,
integration,
code
}: {
workspaceId: string;
integration: string;
code: string;
}) {
console.log('IntegrationService > handleO')
let action;
try {
const bot = await Bot.find({
workspace: workspaceId,
isActive: true
});
if (!bot) throw new Error('Bot must be enabled for OAuth2 code-token exchange');
// exchange code for access and refresh tokens
let res = await exchangeCode({
integration,
code
});
const integrationAuth = await processOAuthTokenRes2({
workspaceId,
integration,
accessToken: res.accessToken,
accessExpiresAt: res.accessExpiresAt,
refreshToken: res.refreshToken
});
await Integration.findOneAndUpdate(
{ workspace: workspaceId, integration },
{
workspace: workspaceId,
environment: ENV_DEV,
isActive: false,
app: null,
integration,
integrationAuth: integrationAuth._id
},
{ upsert: true, new: true }
);
// add bot sequence
await BotSequence.findOneAndUpdate({
bot: bot._id,
name: integration + 'sequence',
event: EVENT_PUSH_SECRETS,
action
});
} catch (err) {
console.error('IntegrationService error', err);
Sentry.setUser(null);
Sentry.captureException(err);
throw new Error('Failed to handle OAuth2 code-token exchange')
}
}
}
export default IntegrationService;

View File

@ -1,5 +1,13 @@
import postHogClient from './PostHogClient';
import BotService from './BotService';
import EventService from './EventService';
import ActionService from './ActionService';
import IntegrationService from './IntegrationService';
export {
postHogClient
postHogClient,
BotService,
EventService,
ActionService,
IntegrationService
}

View File

@ -11,6 +11,7 @@ declare global {
membershipOrg: any;
integration: any;
integrationAuth: any;
bot: any;
serviceToken: any;
accessToken: any;
query?: any;

View File

@ -2,6 +2,21 @@ import nacl from 'tweetnacl';
import util from 'tweetnacl-util';
import AesGCM from './aes-gcm';
/**
* Return new base64, NaCl, public-private key pair.
* @returns {Object} obj
* @returns {String} obj.publicKey - base64, NaCl, public key
* @returns {String} obj.privateKey - base64, NaCl, private key
*/
const generateKeyPair = () => {
const pair = nacl.box.keyPair();
return ({
publicKey: util.encodeBase64(pair.publicKey),
privateKey: util.encodeBase64(pair.secretKey)
});
}
/**
* Return assymmetrically encrypted [plaintext] using [publicKey] where
* [publicKey] likely belongs to the recipient.
@ -139,6 +154,7 @@ const decryptSymmetric = ({
};
export {
generateKeyPair,
encryptAsymmetric,
decryptAsymmetric,
encryptSymmetric,

View File

@ -1,60 +0,0 @@
// membership roles
const OWNER = 'owner';
const ADMIN = 'admin';
const MEMBER = 'member';
// membership statuses
const INVITED = 'invited';
// -- organization
const ACCEPTED = 'accepted';
// -- workspace
const COMPLETED = 'completed';
const GRANTED = 'granted';
// subscriptions
const PLAN_STARTER = 'starter';
const PLAN_PRO = 'pro';
// secrets
const SECRET_SHARED = 'shared';
const SECRET_PERSONAL = 'personal';
// environments
const ENV_DEV = 'dev';
const ENV_TESTING = 'test';
const ENV_STAGING = 'staging';
const ENV_PROD = 'prod';
const ENV_SET = new Set([ENV_DEV, ENV_TESTING, ENV_STAGING, ENV_PROD]);
// integrations
const INTEGRATION_HEROKU = 'heroku';
const INTEGRATION_NETLIFY = 'netlify';
const INTEGRATION_SET = new Set([INTEGRATION_HEROKU, INTEGRATION_NETLIFY]);
// integration types
const INTEGRATION_OAUTH2 = 'oauth2';
export {
OWNER,
ADMIN,
MEMBER,
INVITED,
ACCEPTED,
COMPLETED,
GRANTED,
PLAN_STARTER,
PLAN_PRO,
SECRET_SHARED,
SECRET_PERSONAL,
ENV_DEV,
ENV_TESTING,
ENV_STAGING,
ENV_PROD,
ENV_SET,
INTEGRATION_HEROKU,
INTEGRATION_NETLIFY,
INTEGRATION_SET,
INTEGRATION_OAUTH2
};

View File

@ -0,0 +1,5 @@
const ACTION_PUSH_TO_HEROKU = 'pushToHeroku';
export {
ACTION_PUSH_TO_HEROKU
}

View File

@ -0,0 +1,14 @@
// environments
const ENV_DEV = 'dev';
const ENV_TESTING = 'test';
const ENV_STAGING = 'staging';
const ENV_PROD = 'prod';
const ENV_SET = new Set([ENV_DEV, ENV_TESTING, ENV_STAGING, ENV_PROD]);
export {
ENV_DEV,
ENV_TESTING,
ENV_STAGING,
ENV_PROD,
ENV_SET
}

View File

@ -0,0 +1,7 @@
const EVENT_PUSH_SECRETS = 'pushSecrets';
const EVENT_PULL_SECRETS = 'pullSecrets';
export {
EVENT_PUSH_SECRETS,
EVENT_PULL_SECRETS
}

View File

@ -0,0 +1,65 @@
import {
ENV_DEV,
ENV_TESTING,
ENV_STAGING,
ENV_PROD,
ENV_SET
} from './environment';
import {
INTEGRATION_HEROKU,
INTEGRATION_NETLIFY,
INTEGRATION_SET,
INTEGRATION_OAUTH2,
OAUTH_TOKEN_URL_HEROKU
} from './integration';
import {
OWNER,
ADMIN,
MEMBER,
INVITED,
ACCEPTED,
COMPLETED,
GRANTED
} from './organization';
import {
SECRET_SHARED,
SECRET_PERSONAL
} from './secret';
import {
PLAN_STARTER,
PLAN_PRO
} from './stripe';
import {
EVENT_PUSH_SECRETS,
EVENT_PULL_SECRETS
} from './event';
import {
ACTION_PUSH_TO_HEROKU
} from './action';
export {
OWNER,
ADMIN,
MEMBER,
INVITED,
ACCEPTED,
COMPLETED,
GRANTED,
PLAN_STARTER,
PLAN_PRO,
SECRET_SHARED,
SECRET_PERSONAL,
ENV_DEV,
ENV_TESTING,
ENV_STAGING,
ENV_PROD,
ENV_SET,
INTEGRATION_HEROKU,
INTEGRATION_NETLIFY,
INTEGRATION_SET,
INTEGRATION_OAUTH2,
OAUTH_TOKEN_URL_HEROKU,
EVENT_PUSH_SECRETS,
EVENT_PULL_SECRETS,
ACTION_PUSH_TO_HEROKU
};

View File

@ -0,0 +1,18 @@
// integrations
const INTEGRATION_HEROKU = 'heroku';
const INTEGRATION_NETLIFY = 'netlify';
const INTEGRATION_SET = new Set([INTEGRATION_HEROKU, INTEGRATION_NETLIFY]);
// integration types
const INTEGRATION_OAUTH2 = 'oauth2';
// integration oauth endpoints
const OAUTH_TOKEN_URL_HEROKU = 'https://id.heroku.com/oauth/token';
export {
INTEGRATION_HEROKU,
INTEGRATION_NETLIFY,
INTEGRATION_SET,
INTEGRATION_OAUTH2,
OAUTH_TOKEN_URL_HEROKU
}

View File

@ -0,0 +1,24 @@
// membership roles
const OWNER = 'owner';
const ADMIN = 'admin';
const MEMBER = 'member';
// membership statuses
const INVITED = 'invited';
// -- organization
const ACCEPTED = 'accepted';
// -- workspace
const COMPLETED = 'completed';
const GRANTED = 'granted';
export {
OWNER,
ADMIN,
MEMBER,
INVITED,
ACCEPTED,
COMPLETED,
GRANTED
}

View File

@ -0,0 +1,8 @@
// secrets
const SECRET_SHARED = 'shared';
const SECRET_PERSONAL = 'personal';
export {
SECRET_SHARED,
SECRET_PERSONAL
}

View File

@ -0,0 +1,7 @@
const PLAN_STARTER = 'starter';
const PLAN_PRO = 'pro';
export {
PLAN_STARTER,
PLAN_PRO
}